@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.
- package/dist/Field/Field.d.ts +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +19 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.js +47 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.js +30 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
- package/dist/Select/Select.d.ts +43 -2
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +27 -2
- package/dist/Select/Select.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/index.js +14 -0
- package/dist/markdownEditorCodeMirror/index.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
- package/package.json +36 -1
- package/src/Field/Field.mdx +2 -2
- package/src/Field/Field.stories.tsx +9 -5
- package/src/Field/Field.test.tsx +43 -6
- package/src/Field/Field.tsx +19 -1
- package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
- package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
- package/src/MarkdownEditor/markdownCommands.ts +60 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
- package/src/MarkdownEditor/markdownSpans.ts +35 -0
- package/src/Select/Select.mdx +27 -1
- package/src/Select/Select.stories.tsx +6 -1
- package/src/Select/Select.tsx +43 -2
- package/src/index.ts +5 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
- package/src/markdownEditorCodeMirror/index.ts +26 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
- package/src/sourceRules.test.ts +20 -0
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
insertImage,
|
|
8
8
|
insertLink,
|
|
9
9
|
insertText,
|
|
10
|
+
isLinkPaste,
|
|
10
11
|
MARKDOWN_LINE_PREFIXES,
|
|
11
12
|
outdentLines,
|
|
12
13
|
toggleHeading,
|
|
13
14
|
toggleInlineMarker,
|
|
14
15
|
toggleLinePrefix,
|
|
15
16
|
toMarkdownImage,
|
|
17
|
+
wrapSelectionInLink,
|
|
16
18
|
} from "./markdownCommands.ts"
|
|
17
19
|
|
|
18
20
|
const at = (text: string, marker = "|") => {
|
|
@@ -382,3 +384,56 @@ test("the minimal edit is the smallest range that explains the change", () => {
|
|
|
382
384
|
text: "",
|
|
383
385
|
})
|
|
384
386
|
})
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pasting a URL over selected text is what every editor the owner
|
|
390
|
+
* uses already does, and its absence is why a link pasted into a
|
|
391
|
+
* Docket description stayed flat text.
|
|
392
|
+
*/
|
|
393
|
+
test("a URL pasted over a selection wraps it", () => {
|
|
394
|
+
expect(
|
|
395
|
+
wrapSelectionInLink(
|
|
396
|
+
at("see |the docs| now"),
|
|
397
|
+
"https://example.com",
|
|
398
|
+
),
|
|
399
|
+
).toEqual({
|
|
400
|
+
// After the whole insertion, not inside the destination: the
|
|
401
|
+
// URL is already known, so parking the caret in it would ask
|
|
402
|
+
// the user to replace what they just pasted.
|
|
403
|
+
selectionEnd: 35,
|
|
404
|
+
selectionStart: 35,
|
|
405
|
+
text: "see [the docs](https://example.com) now",
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test("a URL pasted with no selection is inserted plainly", () => {
|
|
410
|
+
// Autolink renders a bare URL as a link anyway, so wrapping it
|
|
411
|
+
// with no text would produce `[](url)` — an empty link.
|
|
412
|
+
expect(
|
|
413
|
+
wrapSelectionInLink(
|
|
414
|
+
at("see | now"),
|
|
415
|
+
"https://example.com",
|
|
416
|
+
),
|
|
417
|
+
).toEqual({
|
|
418
|
+
selectionEnd: 23,
|
|
419
|
+
selectionStart: 23,
|
|
420
|
+
text: "see https://example.com now",
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test.each([
|
|
425
|
+
["https://example.com", true],
|
|
426
|
+
["http://example.com/a?b=c#d", true],
|
|
427
|
+
[" https://example.com ", true],
|
|
428
|
+
["not a url", false],
|
|
429
|
+
["", false],
|
|
430
|
+
// Deliberately strict: a paste that is only *mostly* a URL is
|
|
431
|
+
// left alone, because mangling a literal paste is worse than
|
|
432
|
+
// not linkifying one.
|
|
433
|
+
["see https://example.com", false],
|
|
434
|
+
["https://a.com https://b.com", false],
|
|
435
|
+
["mailto:someone@example.com", false],
|
|
436
|
+
["www.example.com", false],
|
|
437
|
+
])("isLinkPaste(%j) is %s", (pasted, isExpected) => {
|
|
438
|
+
expect(isLinkPaste(pasted)).toBe(isExpected)
|
|
439
|
+
})
|
|
@@ -394,6 +394,66 @@ export const insertLink = (
|
|
|
394
394
|
})
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Does this pasted string want to become a link?
|
|
399
|
+
*
|
|
400
|
+
* Deliberately strict. A paste that is *mostly* a URL — a sentence
|
|
401
|
+
* with one in it, two URLs on two lines — is left alone, because
|
|
402
|
+
* the cost of guessing wrong is silently mangling something the
|
|
403
|
+
* user meant to paste literally. One absolute `http(s)` URL, no
|
|
404
|
+
* inner whitespace, nothing either side of it.
|
|
405
|
+
*
|
|
406
|
+
* `mailto:` and bare `www.` are excluded on the same principle:
|
|
407
|
+
* they are the cases where "did they mean a link?" genuinely has
|
|
408
|
+
* two answers.
|
|
409
|
+
*/
|
|
410
|
+
export const isLinkPaste = (pasted: string): boolean =>
|
|
411
|
+
/^https?:\/\/[^\s<>]+$/.test(pasted.trim())
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Paste a URL over selected text and get `[selected](url)`.
|
|
415
|
+
*
|
|
416
|
+
* Every editor the owner uses does this — Outline, Notion, Slack,
|
|
417
|
+
* GitHub — and its absence is why a link pasted into Docket stayed
|
|
418
|
+
* flat text nobody could click. It is the one paste behaviour worth
|
|
419
|
+
* intercepting, and it is still markdown in and markdown out.
|
|
420
|
+
*
|
|
421
|
+
* Unlike `insertLink`, the caret lands **after** the whole
|
|
422
|
+
* insertion rather than inside the destination: the URL is already
|
|
423
|
+
* known, so parking the selection in it would ask the user to
|
|
424
|
+
* replace what they just pasted.
|
|
425
|
+
*
|
|
426
|
+
* With an empty selection this is a plain insertion of the URL —
|
|
427
|
+
* autolink renders a bare URL as a link anyway, so wrapping it in
|
|
428
|
+
* `[](…)` with no text would produce an empty link instead.
|
|
429
|
+
*/
|
|
430
|
+
export const wrapSelectionInLink = (
|
|
431
|
+
state: MarkdownSelection,
|
|
432
|
+
url: string,
|
|
433
|
+
): MarkdownSelection => {
|
|
434
|
+
const selected = state.text.slice(
|
|
435
|
+
state.selectionStart,
|
|
436
|
+
state.selectionEnd,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if (selected === "") {
|
|
440
|
+
return insertText(state, url)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const insertion = `[${selected}](${url})`
|
|
444
|
+
|
|
445
|
+
const caret = state.selectionStart + insertion.length
|
|
446
|
+
|
|
447
|
+
return replaceRange({
|
|
448
|
+
end: state.selectionEnd,
|
|
449
|
+
insertion,
|
|
450
|
+
selectionEnd: caret,
|
|
451
|
+
selectionStart: caret,
|
|
452
|
+
start: state.selectionStart,
|
|
453
|
+
text: state.text,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
397
457
|
/**
|
|
398
458
|
* What Enter should do inside a list, a task list or a blockquote —
|
|
399
459
|
* or `null` when it should do the ordinary thing and the component
|
|
@@ -215,3 +215,68 @@ test("the caret's line is found from its offset", () => {
|
|
|
215
215
|
|
|
216
216
|
expect(toLineIndex(text, 9999)).toBe(2)
|
|
217
217
|
})
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Gap 2 of Docket's markdown handoff. A URL pasted into a
|
|
221
|
+
* description used to tokenize as prose — no `url` span, nothing to
|
|
222
|
+
* paint, and indistinguishable from the words around it.
|
|
223
|
+
*/
|
|
224
|
+
test("a bare URL paints as a URL without being marked up", () => {
|
|
225
|
+
expect(
|
|
226
|
+
toInlineSpans("see https://example.com now"),
|
|
227
|
+
).toEqual([
|
|
228
|
+
{ kind: "plain", text: "see " },
|
|
229
|
+
{ kind: "url", text: "https://example.com" },
|
|
230
|
+
{ kind: "plain", text: " now" },
|
|
231
|
+
])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("a sentence's full stop is not part of the URL", () => {
|
|
235
|
+
expect(
|
|
236
|
+
toInlineSpans("read https://example.com/a."),
|
|
237
|
+
).toEqual([
|
|
238
|
+
{ kind: "plain", text: "read " },
|
|
239
|
+
{ kind: "url", text: "https://example.com/a" },
|
|
240
|
+
{ kind: "plain", text: "." },
|
|
241
|
+
])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("a parenthesised URL does not swallow the paren", () => {
|
|
245
|
+
expect(
|
|
246
|
+
toInlineSpans("(see https://example.com)"),
|
|
247
|
+
).toEqual([
|
|
248
|
+
{ kind: "plain", text: "(see " },
|
|
249
|
+
{ kind: "url", text: "https://example.com" },
|
|
250
|
+
{ kind: "plain", text: ")" },
|
|
251
|
+
])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The destination inside an explicit link is already a `url` span
|
|
256
|
+
* emitted by the link rule, and the link rule runs first. If
|
|
257
|
+
* autolink won here, the `](` and `)` would stop being markers.
|
|
258
|
+
*/
|
|
259
|
+
test("an explicit link still tokenizes as a link", () => {
|
|
260
|
+
expect(
|
|
261
|
+
toInlineSpans("[docs](https://example.com)"),
|
|
262
|
+
).toEqual([
|
|
263
|
+
{ kind: "marker", text: "[" },
|
|
264
|
+
{ kind: "link", text: "docs" },
|
|
265
|
+
{ kind: "marker", text: "](" },
|
|
266
|
+
{ kind: "url", text: "https://example.com" },
|
|
267
|
+
{ kind: "marker", text: ")" },
|
|
268
|
+
])
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Autolink runs before emphasis, which fixes a bug that predates
|
|
273
|
+
* it: the stretch between two underscores in a URL used to paint
|
|
274
|
+
* as italic text.
|
|
275
|
+
*/
|
|
276
|
+
test("underscores inside a URL are not emphasis", () => {
|
|
277
|
+
expect(
|
|
278
|
+
toInlineSpans("https://example.com/a_b_c"),
|
|
279
|
+
).toEqual([
|
|
280
|
+
{ kind: "url", text: "https://example.com/a_b_c" },
|
|
281
|
+
])
|
|
282
|
+
})
|
|
@@ -121,8 +121,22 @@ const TASK_MARKER_PATTERN = /^\[[ xX]\][ \t]*/
|
|
|
121
121
|
* - **Image before link**, because `` starts with a
|
|
122
122
|
* valid link at index 1.
|
|
123
123
|
* - **Strong before emphasis**, because `**` starts with `*`.
|
|
124
|
+
* - **Link before autolink**, because the `https://…` inside
|
|
125
|
+
* `[text](https://…)` is a destination, not a bare URL, and
|
|
126
|
+
* matching it as one would paint the parentheses as link text.
|
|
124
127
|
*/
|
|
125
128
|
const INLINE_PATTERNS = {
|
|
129
|
+
/**
|
|
130
|
+
* A bare URL, as GFM autolinks it.
|
|
131
|
+
*
|
|
132
|
+
* Trailing punctuation is excluded from the match rather than
|
|
133
|
+
* trimmed after: `see https://example.com.` ends a sentence, and
|
|
134
|
+
* the full stop belongs to the prose. The closing paren is
|
|
135
|
+
* likewise left out so `(see https://example.com)` does not
|
|
136
|
+
* swallow it — at the cost of clipping a URL that genuinely ends
|
|
137
|
+
* in one, which is the rarer and less annoying failure.
|
|
138
|
+
*/
|
|
139
|
+
autolink: /https?:\/\/[^\s<>[\]()]+[^\s<>[\]().,;:!?'"]/y,
|
|
126
140
|
code: /(`+)([^\n]*?)\1/y,
|
|
127
141
|
emphasis: /([*_])(?=[^\s*_])([^\n*_]*?[^\s*_])\1/y,
|
|
128
142
|
escape: /\\([\\`*_{}[\]()#+\-.!>~|])/y,
|
|
@@ -243,6 +257,27 @@ export const toInlineSpans = (
|
|
|
243
257
|
continue
|
|
244
258
|
}
|
|
245
259
|
|
|
260
|
+
INLINE_PATTERNS.autolink.lastIndex = index
|
|
261
|
+
|
|
262
|
+
const autolinked = INLINE_PATTERNS.autolink.exec(text)
|
|
263
|
+
|
|
264
|
+
if (autolinked) {
|
|
265
|
+
// One span, no markers — there is no syntax to dim, because
|
|
266
|
+
// the user typed none. It paints as a URL so that a pasted
|
|
267
|
+
// link reads as a link, which is the whole complaint this
|
|
268
|
+
// answers: a URL dropped into a description used to be
|
|
269
|
+
// indistinguishable from prose.
|
|
270
|
+
//
|
|
271
|
+
// Running before `emphasis` also fixes a bug nobody filed:
|
|
272
|
+
// a URL with two underscores in it used to italicise the
|
|
273
|
+
// stretch between them.
|
|
274
|
+
pushSpan(spans, "url", autolinked[0])
|
|
275
|
+
|
|
276
|
+
index += autolinked[0].length
|
|
277
|
+
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
246
281
|
INLINE_PATTERNS.strong.lastIndex = index
|
|
247
282
|
|
|
248
283
|
const strong = INLINE_PATTERNS.strong.exec(text)
|
package/src/Select/Select.mdx
CHANGED
|
@@ -4,7 +4,33 @@ import * as SelectStories from "./Select.stories";
|
|
|
4
4
|
|
|
5
5
|
<Meta of={SelectStories} />
|
|
6
6
|
|
|
7
|
-
# Select
|
|
7
|
+
# Select (deprecated)
|
|
8
|
+
|
|
9
|
+
> **Deprecated 2026-08-20. Nothing new gets a native `Select`.**
|
|
10
|
+
>
|
|
11
|
+
> Use **`Picker`** — a `Listbox` with the trigger already attached, and a near drop-in for
|
|
12
|
+
> this component — or **`Combobox`** when the list is long enough to want typing.
|
|
13
|
+
>
|
|
14
|
+
> ```tsx
|
|
15
|
+
> // Before.
|
|
16
|
+
> <Select label="Quality" onChange={setQuality} options={QUALITY_OPTIONS} value={quality} />
|
|
17
|
+
>
|
|
18
|
+
> // After.
|
|
19
|
+
> <Picker label="Quality" onChange={setQuality} options={QUALITY_OPTIONS} value={quality} />
|
|
20
|
+
> ```
|
|
21
|
+
>
|
|
22
|
+
> The [2026-08-10 demotion](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md)
|
|
23
|
+
> left four platform cases where native was still the right call — the mobile wheel picker,
|
|
24
|
+
> autofill, `:invalid`, and a form that posts with no JS. **Not one of them has ever applied
|
|
25
|
+
> to an app in this fleet**, and the exception is what agents kept reading as permission. It
|
|
26
|
+
> is closed: a new native `Select` is a new decision with the reason written down, not a
|
|
27
|
+
> call-site choice. `Listbox` has no `<optgroup>` equivalent, and that gap is a change to
|
|
28
|
+
> `Listbox` rather than a reason to keep this.
|
|
29
|
+
>
|
|
30
|
+
> It stays exported so existing call sites keep compiling while they convert, and it will be
|
|
31
|
+
> removed in the next major of `@charcuterie/ui`.
|
|
32
|
+
|
|
33
|
+
Everything below is how the component works, for the call sites still on it.
|
|
8
34
|
|
|
9
35
|
Eighteen sites in the fleet, and they split cleanly in two: **twelve native `<select>`s**
|
|
10
36
|
that differ only in their Tailwind classes, and **six hand-rolled `role="listbox"`
|
|
@@ -41,7 +41,12 @@ const GROUPED: SelectItem[] = [
|
|
|
41
41
|
]
|
|
42
42
|
|
|
43
43
|
const meta = {
|
|
44
|
-
|
|
44
|
+
// `Deprecated/`, not `Components/`, and that is the whole point of
|
|
45
|
+
// moving it: the sidebar is the first place an agent looks for a
|
|
46
|
+
// picker, and a `Select` sitting between `Rail` and `Shell` reads
|
|
47
|
+
// as a component in good standing. Nothing new gets one — `Picker`
|
|
48
|
+
// is the drop-in. See the 2026-08-20 deprecation record.
|
|
49
|
+
title: "Deprecated/Select",
|
|
45
50
|
component: Select,
|
|
46
51
|
parameters: { layout: "padded" },
|
|
47
52
|
argTypes: { size: controlSizeArgType },
|
package/src/Select/Select.tsx
CHANGED
|
@@ -12,20 +12,36 @@ import {
|
|
|
12
12
|
} from "../intentStyles.ts"
|
|
13
13
|
import { toClassName } from "../toClassName.ts"
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated Part of the deprecated native `Select`. The
|
|
17
|
+
* replacement shape is `PickerOption` / `ListboxItem`, whose
|
|
18
|
+
* `label` is a `ReactNode` rather than a `string`.
|
|
19
|
+
*/
|
|
15
20
|
export type SelectOption = {
|
|
16
21
|
isDisabled?: boolean
|
|
17
22
|
label: string
|
|
18
23
|
value: string
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* @deprecated Part of the deprecated native `Select`. `Listbox`
|
|
28
|
+
* has **no `<optgroup>` equivalent**, so a grouped picker is the
|
|
29
|
+
* one shape this deprecation has no replacement for — an app that
|
|
30
|
+
* needs one is a change to `Listbox`, not a reason to keep
|
|
31
|
+
* reaching for the native control.
|
|
32
|
+
*/
|
|
21
33
|
export type SelectOptionGroup = {
|
|
22
34
|
label: string
|
|
23
35
|
options: readonly SelectOption[]
|
|
24
36
|
}
|
|
25
37
|
|
|
38
|
+
/** @deprecated Part of the deprecated native `Select`. */
|
|
26
39
|
export type SelectItem = SelectOption | SelectOptionGroup
|
|
27
40
|
|
|
28
41
|
/**
|
|
42
|
+
* @deprecated Part of the deprecated native `Select` — see the
|
|
43
|
+
* component's docblock. `PickerProps` is the replacement.
|
|
44
|
+
*
|
|
29
45
|
* The HTML attributes are passed through, and that is not
|
|
30
46
|
* convenience — it is what makes the `Field` slot work at all.
|
|
31
47
|
*
|
|
@@ -95,8 +111,33 @@ const getIsGroup = (
|
|
|
95
111
|
): item is SelectOptionGroup => "options" in item
|
|
96
112
|
|
|
97
113
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
114
|
+
* @deprecated Reach for `Picker` — a `Listbox` with the trigger
|
|
115
|
+
* already attached, and a near drop-in for this component
|
|
116
|
+
* (`label`, `options`, `value`, `onChange`) — or `Combobox` when
|
|
117
|
+
* the list is long enough to want typing.
|
|
118
|
+
*
|
|
119
|
+
* A styled native `<select>`, kept only so the call sites that
|
|
120
|
+
* already have one keep compiling while they are converted.
|
|
121
|
+
*
|
|
122
|
+
* ### Deprecated 2026-08-20, and the platform hatch is closed
|
|
123
|
+
*
|
|
124
|
+
* The 2026-08-10 record demoted this to a stated-reason exception
|
|
125
|
+
* and named four cases where the platform behaviour is the
|
|
126
|
+
* feature: the mobile wheel picker, autofill, `:invalid`, and a
|
|
127
|
+
* form that posts with no JS. **None of them has ever applied to
|
|
128
|
+
* an app in this fleet**, and the exception is what agents kept
|
|
129
|
+
* reading as permission — so the exception is gone. A new native
|
|
130
|
+
* `Select` is a new decision with the reason written down, not a
|
|
131
|
+
* call-site choice.
|
|
132
|
+
*
|
|
133
|
+
* The objection is that the popup is painted by the OS. No token,
|
|
134
|
+
* no class and no amount of `appearance-none` reaches inside it,
|
|
135
|
+
* so a fleet with any of these in it has two focus treatments, two
|
|
136
|
+
* hover treatments and two keyboard feels — which is the thing the
|
|
137
|
+
* library exists to stop.
|
|
138
|
+
*
|
|
139
|
+
* Everything below is how the component works, for the call sites
|
|
140
|
+
* still on it. It is not a reason to add one.
|
|
100
141
|
*
|
|
101
142
|
* ### Sizing: `className` is the wrapper, `controlClassName` is the select
|
|
102
143
|
*
|
package/src/index.ts
CHANGED
|
@@ -298,6 +298,11 @@ export type {
|
|
|
298
298
|
SegmentedItem,
|
|
299
299
|
} from "./SegmentedControl/SegmentedControl.tsx"
|
|
300
300
|
export { SegmentedControl } from "./SegmentedControl/SegmentedControl.tsx"
|
|
301
|
+
// DEPRECATED 2026-08-20. `Select` is the native `<select>` and
|
|
302
|
+
// nothing new gets one — `Picker` is the drop-in, `Combobox` when
|
|
303
|
+
// the list wants typing. It stays exported so the fleet's existing
|
|
304
|
+
// call sites keep compiling while they convert, and for no other
|
|
305
|
+
// reason; see the decision record next to the component.
|
|
301
306
|
export type {
|
|
302
307
|
SelectItem,
|
|
303
308
|
SelectOption,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as MarkdownEditorCodeMirrorStories from "./MarkdownEditorCodeMirror.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={MarkdownEditorCodeMirrorStories} />
|
|
6
|
+
|
|
7
|
+
# MarkdownEditorCodeMirror
|
|
8
|
+
|
|
9
|
+
Obsidian-style live preview: markers conceal themselves once the caret leaves, headings are
|
|
10
|
+
genuinely larger, images render in place, task boxes are real checkboxes — and the stored
|
|
11
|
+
value is still markdown, byte for byte.
|
|
12
|
+
|
|
13
|
+
It is **opt-in**, from a subpath:
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { MarkdownEditorCodeMirror } from "@charcuterie/ui/markdown-editor-codemirror"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.Default} />
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
<Controls />
|
|
24
|
+
|
|
25
|
+
## Which of the two editors you want
|
|
26
|
+
|
|
27
|
+
`MarkdownEditor` is still the default, and most consumers should keep it.
|
|
28
|
+
|
|
29
|
+
| | `MarkdownEditor` | `MarkdownEditorCodeMirror` |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| Surface | `<textarea>` + painted layer | CodeMirror 6 |
|
|
32
|
+
| New dependency | **none** | ~176 KB gz, optional peer |
|
|
33
|
+
| Markers | dimmed, never hidden | **concealed** until the caret arrives |
|
|
34
|
+
| Headings | weight and colour only | **actually larger** |
|
|
35
|
+
| Images | shown as `` | **rendered in place** |
|
|
36
|
+
| Tasks | shown as `- [ ]` | **checkbox you can click** |
|
|
37
|
+
| Bare URLs | painted as a URL | **clickable link** |
|
|
38
|
+
| Undo, IME, spellcheck | the platform's own | CodeMirror's |
|
|
39
|
+
|
|
40
|
+
Take the subpath when the description field is a **primary** surface of the app and the
|
|
41
|
+
bundle has room. Keep the default when it is one field on a form — a component library that
|
|
42
|
+
makes every consumer install an editor core to render a description has made the wrong
|
|
43
|
+
trade, and `castkit/packages/slatecast` has 60 KB gz of total budget to prove it.
|
|
44
|
+
|
|
45
|
+
## Markdown is still the stored value
|
|
46
|
+
|
|
47
|
+
This is the part worth being exact about, because "WYSIWYG" usually means the opposite.
|
|
48
|
+
|
|
49
|
+
There is **no document model here and no serialiser**. CodeMirror's state holds the markdown
|
|
50
|
+
*text*. Live preview is a set of decorations *over* that text: concealing `**` hides two
|
|
51
|
+
characters that are still in the document, still in `onChange`, and still in whatever you
|
|
52
|
+
save. Replacing `` with an image does not rewrite the line — move the caret into
|
|
53
|
+
it and the markup is simply there again.
|
|
54
|
+
|
|
55
|
+
That is why this is not TipTap or Lexical. An editor core inverts the relationship: its
|
|
56
|
+
document model becomes authoritative and markdown becomes a lossy *export*, so any construct
|
|
57
|
+
the serialiser does not know about is silently dropped or silently becomes HTML. Docket
|
|
58
|
+
exists partly because HTML in a description field destroyed a previous tracker's data, and
|
|
59
|
+
an editor core would have been the easier build and the one thing that could not be allowed.
|
|
60
|
+
|
|
61
|
+
The [decision record][decision] has the measured bundle table and the full argument.
|
|
62
|
+
|
|
63
|
+
[decision]: https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md
|
|
64
|
+
|
|
65
|
+
## Concealment is the caret's answer, not the parser's
|
|
66
|
+
|
|
67
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.CaretReveal} />
|
|
68
|
+
|
|
69
|
+
A construct reveals its markers when the selection **touches** it, and hides them again when
|
|
70
|
+
the selection leaves. Two consequences that are easy to mistake for bugs:
|
|
71
|
+
|
|
72
|
+
- **An unfocused editor reveals nothing.** There is no caret in a document nobody is editing,
|
|
73
|
+
so the resting offset of `0` deliberately does not count as "inside" the first construct.
|
|
74
|
+
Without this, a description that opens with a heading rendered its `#` on load and no other
|
|
75
|
+
heading's.
|
|
76
|
+
- **Reveal is per-construct, not per-line.** Two bold runs on one line reveal independently.
|
|
77
|
+
|
|
78
|
+
## The "Markdown source" toggle
|
|
79
|
+
|
|
80
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.RawMode} />
|
|
81
|
+
|
|
82
|
+
Nothing is concealed, nothing is replaced by a widget, no line is rescaled, and the surface
|
|
83
|
+
switches to monospace — because a source view is code. The syntax stays **coloured**: the
|
|
84
|
+
point of the toggle is to see the markup, not to lose the highlighting.
|
|
85
|
+
|
|
86
|
+
It is a `Switch` rather than a toolbar button because it is state, not a verb, and a
|
|
87
|
+
`ToolbarAction` renders a `Button` that announces nothing about being on.
|
|
88
|
+
|
|
89
|
+
## What a mark does when it is hidden
|
|
90
|
+
|
|
91
|
+
Not every marker conceals, and the rule is narrower than "block marks stay":
|
|
92
|
+
|
|
93
|
+
> **A mark persists unless something else already says what it said.**
|
|
94
|
+
|
|
95
|
+
A blockquote gets an accent bar down its inline start, so its `>` is redundant and hides. A
|
|
96
|
+
fenced code block keeps its ` ``` ` and its language tag, because concealing them leaves a
|
|
97
|
+
blank line at the top and bottom of the block that reads as a rendering bug. A list bullet
|
|
98
|
+
stays, because hiding `-` turns an unordered list into indented prose — Obsidian substitutes
|
|
99
|
+
a `•`, which means choosing a width, and this component has no business choosing one.
|
|
100
|
+
|
|
101
|
+
## Clicking a link opens it
|
|
102
|
+
|
|
103
|
+
Obsidian's behaviour, not VS Code's: where the markup is hidden, a link that looks like a
|
|
104
|
+
link should act like one. Bare URLs count — GFM autolinks them, so a URL pasted into a
|
|
105
|
+
description is clickable with nothing typed around it.
|
|
106
|
+
|
|
107
|
+
The trade is real and worth stating: **you cannot click into link text to edit it.** Arrow in
|
|
108
|
+
from either edge, or flip the Markdown source toggle, which is exactly what it is for.
|
|
109
|
+
|
|
110
|
+
## Pasting
|
|
111
|
+
|
|
112
|
+
- **An image** (or a drop) runs `onUploadImage` and inserts `` at the caret, then
|
|
113
|
+
renders it in place. The component does not own storage — it hands you a `File`.
|
|
114
|
+
- **A URL over selected text** becomes `[selection](url)`, the way it does in every other
|
|
115
|
+
editor. Deliberately strict: one absolute `http(s)` URL and nothing else, because mangling
|
|
116
|
+
a literal paste is worse than not linkifying one.
|
|
117
|
+
- **Anything else** is left to the browser.
|
|
118
|
+
|
|
119
|
+
## Accessibility, and the honest cost
|
|
120
|
+
|
|
121
|
+
The `<textarea>` sibling gets caret movement, IME composition, spellcheck, the platform undo
|
|
122
|
+
stack and `role="textbox"` from the browser, tested by the vendors. A `contenteditable`
|
|
123
|
+
re-earns every one of those, and CodeMirror is the reason that is acceptable rather than
|
|
124
|
+
reckless — it is the most heavily exercised implementation of that re-earning there is. It is
|
|
125
|
+
still a re-implementation, and that is what this subpath costs beyond its bytes.
|
|
126
|
+
|
|
127
|
+
`Tab` is **never** captured, at any time. Indenting a list with Tab is the convention in
|
|
128
|
+
editors that own the whole page, and inside a form it is a keyboard trap — WCAG 2.1.2 wants
|
|
129
|
+
focus to leave every component with the standard gesture. Indent and outdent are `Ctrl+]` and
|
|
130
|
+
`Ctrl+[`. `indentWithTab` is the extension that would change this, and it is deliberately not
|
|
131
|
+
installed.
|
|
132
|
+
|
|
133
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.AllStates} />
|
|
134
|
+
|
|
135
|
+
## It is uncontrolled
|
|
136
|
+
|
|
137
|
+
`defaultValue` in, `onChange` out — the same contract as `Select` and as the sibling editor.
|
|
138
|
+
An `EditorView` owns the caret, the selection, the scroll position and the undo history, and
|
|
139
|
+
rebuilding it throws all four away, so the view is built exactly **once**. Props that can
|
|
140
|
+
legitimately change afterwards reach it through a compartment (`isReadOnly`, `isDisabled`) or
|
|
141
|
+
through a ref (the callbacks); the rest are frozen at mount.
|
|
142
|
+
|
|
143
|
+
To swap documents — Docket opening a different task — remount with a `key`. That is one line,
|
|
144
|
+
and it is honest about the old undo history belonging to the old document.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
StoryCell,
|
|
5
|
+
StoryGrid,
|
|
6
|
+
} from "../board.storyHelpers.tsx"
|
|
7
|
+
import { Field } from "../Field/Field.tsx"
|
|
8
|
+
import { MarkdownEditorCodeMirror } from "./MarkdownEditorCodeMirror.tsx"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Invented, like every fixture in this repo — a task in a made-up
|
|
12
|
+
* homelab project, exercising each construct the live-preview layer
|
|
13
|
+
* knows about.
|
|
14
|
+
*
|
|
15
|
+
* The bare URL on the last line is the point of the whole exercise:
|
|
16
|
+
* nobody typed `[](…)` around it, and it is a link anyway.
|
|
17
|
+
*/
|
|
18
|
+
const SAMPLE = `# Rack move, phase two
|
|
19
|
+
|
|
20
|
+
The **short** version: pull the _old_ switch, land the new one, and
|
|
21
|
+
keep the ~~three~~ two uplinks live throughout.
|
|
22
|
+
|
|
23
|
+
## Before the window
|
|
24
|
+
|
|
25
|
+
- [x] Label every patch lead
|
|
26
|
+
- [ ] Photograph the current cabling
|
|
27
|
+
- [ ] Cut over at 02:00
|
|
28
|
+
|
|
29
|
+
1. Power down the shelf
|
|
30
|
+
2. Swap the rails
|
|
31
|
+
3. Bring it back up
|
|
32
|
+
|
|
33
|
+
> Nothing here touches the storage pool.
|
|
34
|
+
|
|
35
|
+
Run \`ping -c 3 gateway.invalid\` before and after, and read
|
|
36
|
+
[the runbook](https://example.invalid/runbook) first.
|
|
37
|
+
|
|
38
|
+
| Port | Goes to |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| 1 | uplink |
|
|
41
|
+
| 2 | shelf |
|
|
42
|
+
|
|
43
|
+
\`\`\`sh
|
|
44
|
+
ip -br addr show
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
Replacement rails ordered from https://example.invalid/product?id=1234
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
const meta = {
|
|
51
|
+
title: "Components/MarkdownEditorCodeMirror",
|
|
52
|
+
component: MarkdownEditorCodeMirror,
|
|
53
|
+
parameters: { layout: "padded" },
|
|
54
|
+
args: {
|
|
55
|
+
isDisabled: false,
|
|
56
|
+
isReadOnly: false,
|
|
57
|
+
label: "Description",
|
|
58
|
+
placeholder: "Write it in markdown…",
|
|
59
|
+
},
|
|
60
|
+
} satisfies Meta<typeof MarkdownEditorCodeMirror>
|
|
61
|
+
|
|
62
|
+
export default meta
|
|
63
|
+
|
|
64
|
+
type Story = StoryObj<typeof meta>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The whole argument for this subpath, in one frame: the heading is
|
|
68
|
+
* genuinely larger, `**` is nowhere on screen, the checkboxes are
|
|
69
|
+
* checkboxes, and the bare URL is a link.
|
|
70
|
+
*/
|
|
71
|
+
export const Default: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
defaultValue: SAMPLE,
|
|
74
|
+
label: "Description",
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The "edit Markdown" toggle, flipped.
|
|
80
|
+
*
|
|
81
|
+
* Nothing is concealed and no line is rescaled — but the syntax is
|
|
82
|
+
* still coloured, because the point of the toggle is to see the
|
|
83
|
+
* markup, not to lose the highlighting.
|
|
84
|
+
*/
|
|
85
|
+
export const RawMode: Story = {
|
|
86
|
+
args: {
|
|
87
|
+
defaultValue: SAMPLE,
|
|
88
|
+
isRawModeDefault: true,
|
|
89
|
+
label: "Description",
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const AllStates: Story = {
|
|
94
|
+
args: { label: "Description" },
|
|
95
|
+
render: () => (
|
|
96
|
+
<StoryGrid columns={2}>
|
|
97
|
+
<StoryCell align="stretch" label="empty">
|
|
98
|
+
<MarkdownEditorCodeMirror
|
|
99
|
+
label="Empty description"
|
|
100
|
+
placeholder="Write it in markdown…"
|
|
101
|
+
/>
|
|
102
|
+
</StoryCell>
|
|
103
|
+
|
|
104
|
+
<StoryCell align="stretch" label="read only">
|
|
105
|
+
<MarkdownEditorCodeMirror
|
|
106
|
+
defaultValue={
|
|
107
|
+
"A **done** description.\n\n- [x] shipped"
|
|
108
|
+
}
|
|
109
|
+
isReadOnly
|
|
110
|
+
label="Read-only description"
|
|
111
|
+
/>
|
|
112
|
+
</StoryCell>
|
|
113
|
+
|
|
114
|
+
<StoryCell align="stretch" label="disabled">
|
|
115
|
+
<MarkdownEditorCodeMirror
|
|
116
|
+
defaultValue={"You cannot edit _this_ one."}
|
|
117
|
+
isDisabled
|
|
118
|
+
label="Disabled description"
|
|
119
|
+
/>
|
|
120
|
+
</StoryCell>
|
|
121
|
+
|
|
122
|
+
<StoryCell align="stretch" label="in a Field">
|
|
123
|
+
<Field
|
|
124
|
+
description="Markdown. Paste a screenshot straight in."
|
|
125
|
+
label="Description"
|
|
126
|
+
>
|
|
127
|
+
<MarkdownEditorCodeMirror defaultValue="Wired through `SlotProps`." />
|
|
128
|
+
</Field>
|
|
129
|
+
</StoryCell>
|
|
130
|
+
</StoryGrid>
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Concealment is per-construct, and the caret is what reveals it.
|
|
136
|
+
*
|
|
137
|
+
* This story exists because the behaviour is invisible in a static
|
|
138
|
+
* frame: click into the bold run and the `**` comes back, in the
|
|
139
|
+
* flow, without the line reflowing around it.
|
|
140
|
+
*/
|
|
141
|
+
export const CaretReveal: Story = {
|
|
142
|
+
args: {
|
|
143
|
+
defaultValue:
|
|
144
|
+
"Click into the **bold run** and its markers come back.\n\nLeave, and they go again.",
|
|
145
|
+
label: "Description",
|
|
146
|
+
},
|
|
147
|
+
}
|