@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,217 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import {
4
+ toInlineSpans,
5
+ toLineIndex,
6
+ toMarkdownLines,
7
+ } from "./markdownSpans.ts"
8
+
9
+ /**
10
+ * The corpus every invariant below runs over. Deliberately nasty:
11
+ * the constructs the editor colours, the ones it does not, and the
12
+ * characters that tempt an HTML-escaping implementation.
13
+ */
14
+ const CORPUS = [
15
+ "",
16
+ "\n",
17
+ "plain text",
18
+ "# Heading one",
19
+ "###### Heading six with **bold** and `code`",
20
+ "#no-space-is-not-a-heading",
21
+ " ## indented heading",
22
+ "> quoted line",
23
+ ">> doubly quoted",
24
+ "> ## a heading inside a quote",
25
+ "- bullet",
26
+ "* star bullet",
27
+ "+ plus bullet",
28
+ "1. ordered",
29
+ "12) ordered with a paren",
30
+ "- [ ] unchecked task",
31
+ "- [x] checked task",
32
+ " - nested bullet",
33
+ "---",
34
+ "***",
35
+ "| a | b |",
36
+ "| --- | --- |",
37
+ "| `code` | **bold** |",
38
+ "text with **bold** and _em_ and ~~struck~~ and `code`",
39
+ "an ![image](https://example.test/a.png) inline",
40
+ "a [link](https://example.test/page) inline",
41
+ "escaped \\*not emphasis\\* here",
42
+ "`**not bold inside code**`",
43
+ "unterminated **bold",
44
+ "unterminated `code",
45
+ "```ts",
46
+ "const x = '**not bold**'",
47
+ "```",
48
+ "5 * 3 * 2 is arithmetic",
49
+ "snake_case_identifier stays plain",
50
+ "<script>alert('xss')</script>",
51
+ "a < b && c > d",
52
+ "&amp; &lt; &#x27; &quot;",
53
+ "1 < 2 > 3 <b>bold?</b>",
54
+ "trailing spaces ",
55
+ "\ttab indented",
56
+ ].join("\n")
57
+
58
+ /**
59
+ * **The invariant the whole component rests on.**
60
+ *
61
+ * The painted layer sits behind a real `<textarea>` in one grid
62
+ * cell, and the two only stay registered while they contain the
63
+ * same characters in the same order. Drop a trailing space,
64
+ * normalise a tab, swallow an unterminated marker — any of them
65
+ * shears the caret away from the glyphs, by a character per
66
+ * offence, cumulative down the line. Nothing else would catch it:
67
+ * the colours look right, axe is silent, and it only shows up as a
68
+ * cursor that is slightly in the wrong place.
69
+ */
70
+ test("every character survives tokenizing", () => {
71
+ const rebuilt = toMarkdownLines(CORPUS)
72
+ .map((line) =>
73
+ line.spans.map((span) => span.text).join(""),
74
+ )
75
+ .join("\n")
76
+
77
+ expect(rebuilt).toBe(CORPUS)
78
+ })
79
+
80
+ test("the line count matches the source exactly", () => {
81
+ expect(toMarkdownLines(CORPUS)).toHaveLength(
82
+ CORPUS.split("\n").length,
83
+ )
84
+
85
+ // An empty document still has one line, because the caret has to
86
+ // sit somewhere and the painted layer needs a row to agree about.
87
+ expect(toMarkdownLines("")).toHaveLength(1)
88
+ })
89
+
90
+ /**
91
+ * The no-HTML guarantee, at the layer where an implementation is
92
+ * most tempted to break it. Nothing here builds a string for
93
+ * `innerHTML`, so a tag in the source has to come back out as
94
+ * *text* — not as `&lt;script&gt;`, and not as markup.
95
+ */
96
+ test("HTML in the source stays literal text", () => {
97
+ const source =
98
+ "<script>alert('x')</script> & <b>bold</b> \"quoted\" 'single'"
99
+
100
+ const spans = toMarkdownLines(source).flatMap(
101
+ (line) => line.spans,
102
+ )
103
+
104
+ const rebuilt = spans.map((span) => span.text).join("")
105
+
106
+ expect(rebuilt).toBe(source)
107
+
108
+ expect(rebuilt).not.toContain("&lt;")
109
+
110
+ expect(rebuilt).not.toContain("&amp;")
111
+ })
112
+
113
+ test("headings, lists, quotes and tables are told apart", () => {
114
+ const kinds = toMarkdownLines(
115
+ [
116
+ "# Heading",
117
+ "- bullet",
118
+ "> quote",
119
+ "| a |",
120
+ "---",
121
+ "plain",
122
+ "```",
123
+ "fenced",
124
+ "```",
125
+ ].join("\n"),
126
+ ).map((line) => line.kind)
127
+
128
+ expect(kinds).toEqual([
129
+ "heading",
130
+ "list",
131
+ "blockquote",
132
+ "table",
133
+ "thematicBreak",
134
+ "paragraph",
135
+ "code",
136
+ "code",
137
+ "code",
138
+ ])
139
+ })
140
+
141
+ test("a fence suppresses every construct inside it", () => {
142
+ const [, inside] = toMarkdownLines(
143
+ "```\n**not bold** and [not a link](x)\n```",
144
+ )
145
+
146
+ expect(inside?.spans).toEqual([
147
+ {
148
+ kind: "code",
149
+ text: "**not bold** and [not a link](x)",
150
+ },
151
+ ])
152
+ })
153
+
154
+ test("a code span suppresses emphasis inside it", () => {
155
+ expect(toInlineSpans("`**a**`")).toEqual([
156
+ { kind: "marker", text: "`" },
157
+ { kind: "code", text: "**a**" },
158
+ { kind: "marker", text: "`" },
159
+ ])
160
+ })
161
+
162
+ test("an escape wins over the character it escapes", () => {
163
+ expect(toInlineSpans("\\*a\\*")).toEqual([
164
+ { kind: "marker", text: "\\" },
165
+ { kind: "plain", text: "*a" },
166
+ { kind: "marker", text: "\\" },
167
+ { kind: "plain", text: "*" },
168
+ ])
169
+ })
170
+
171
+ test("strong beats emphasis, because `**` starts with `*`", () => {
172
+ expect(toInlineSpans("**a**")).toEqual([
173
+ { kind: "marker", text: "**" },
174
+ { kind: "strong", text: "a" },
175
+ { kind: "marker", text: "**" },
176
+ ])
177
+ })
178
+
179
+ test("an image is told apart from the link inside it", () => {
180
+ expect(toInlineSpans("![alt](u)")).toEqual([
181
+ { kind: "marker", text: "![" },
182
+ { kind: "link", text: "alt" },
183
+ { kind: "marker", text: "](" },
184
+ { kind: "url", text: "u" },
185
+ { kind: "marker", text: ")" },
186
+ ])
187
+ })
188
+
189
+ /**
190
+ * Adjacent spans of one kind are merged as they are pushed. Without
191
+ * it a 200-character paragraph is 200 React elements and every
192
+ * keystroke reconciles all of them.
193
+ */
194
+ test("plain runs are one span, not one per character", () => {
195
+ expect(toInlineSpans("hello world")).toEqual([
196
+ { kind: "plain", text: "hello world" },
197
+ ])
198
+ })
199
+
200
+ test("the caret's line is found from its offset", () => {
201
+ const text = "one\ntwo\nthree"
202
+
203
+ expect(toLineIndex(text, 0)).toBe(0)
204
+
205
+ expect(toLineIndex(text, 3)).toBe(0)
206
+
207
+ expect(toLineIndex(text, 4)).toBe(1)
208
+
209
+ expect(toLineIndex(text, text.length)).toBe(2)
210
+
211
+ // Out of range on both ends rather than throwing: the caret is
212
+ // read from a live control and a stale offset must not crash a
213
+ // render.
214
+ expect(toLineIndex(text, -10)).toBe(0)
215
+
216
+ expect(toLineIndex(text, 9999)).toBe(2)
217
+ })
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Markdown → coloured spans, with **every character kept**.
3
+ *
4
+ * This is the half of the live-hybrid editor that decides what a
5
+ * line looks like, and its one hard requirement is not a parsing
6
+ * requirement at all — it is a geometry one.
7
+ *
8
+ * ### The concatenation invariant
9
+ *
10
+ * The editing surface is a real `<textarea>` with a painted layer
11
+ * behind it, sharing one grid cell. The textarea owns the caret,
12
+ * the selection, the undo stack and the IME; the layer owns the
13
+ * colour. That only works while the two layers **wrap identically**,
14
+ * and they only wrap identically while they contain the *same
15
+ * characters in the same order*.
16
+ *
17
+ * So `toMarkdownLines(text)` is not a parser in the usual sense. It
18
+ * never drops a marker, never normalises whitespace, never resolves
19
+ * a reference, and never emits a node the source did not contain.
20
+ * Joining every span's text back together, with `\n` between lines,
21
+ * reproduces the input byte for byte — `markdownSpans.test.ts`
22
+ * asserts exactly that over a fixture corpus, and it is the test
23
+ * that stops a well-meaning "let's trim the trailing space"
24
+ * from silently shearing the caret away from the glyphs.
25
+ *
26
+ * ### It emits kinds, not HTML
27
+ *
28
+ * The return value is a list of `{ kind, text }` pairs. There is no
29
+ * HTML string anywhere in this module, no `innerHTML`, and no
30
+ * sanitiser — because there is nothing to sanitise. Docket's whole
31
+ * reason to exist is that HTML-in-the-field destroyed a previous
32
+ * tool's data, and the cheapest possible guarantee against that is
33
+ * a pipeline in which HTML is never *constructed*. React renders
34
+ * these spans as text nodes; a `<script>` in the source is a
35
+ * `<script>` on screen.
36
+ *
37
+ * ### Why it is line-based, and what that costs
38
+ *
39
+ * A CommonMark parser resolves lazy continuation, setext headings,
40
+ * link reference definitions and nested block containers across the
41
+ * whole document. This resolves one line at a time, carrying
42
+ * exactly one bit of cross-line state: whether we are inside a
43
+ * fenced code block.
44
+ *
45
+ * That is a deliberate trade. Painting is per-line and re-runs on
46
+ * every keystroke, so the cost has to be linear in the line, not in
47
+ * the document; and a mis-coloured marker is a cosmetic defect
48
+ * while a mis-*placed* one is a broken editor. The known gaps are
49
+ * written down in `MarkdownEditor.mdx` rather than hidden: setext
50
+ * headings, indented (four-space) code blocks, reference links, and
51
+ * emphasis that spans a line break all colour as plain text. They
52
+ * still **store** correctly, because the stored value is the
53
+ * textarea's, not this module's.
54
+ */
55
+
56
+ export type MarkdownSpanKind =
57
+ | "code"
58
+ | "emphasis"
59
+ | "heading"
60
+ | "link"
61
+ | "marker"
62
+ | "plain"
63
+ | "strikethrough"
64
+ | "strong"
65
+ | "url"
66
+
67
+ export type MarkdownSpan = {
68
+ kind: MarkdownSpanKind
69
+ text: string
70
+ }
71
+
72
+ export type MarkdownLineKind =
73
+ | "blockquote"
74
+ | "code"
75
+ | "heading"
76
+ | "list"
77
+ | "paragraph"
78
+ | "table"
79
+ | "thematicBreak"
80
+
81
+ export type MarkdownLine = {
82
+ kind: MarkdownLineKind
83
+ spans: MarkdownSpan[]
84
+ }
85
+
86
+ /**
87
+ * A fence opens or closes on any line whose first non-space run is
88
+ * three or more backticks or tildes. Up to three leading spaces,
89
+ * per CommonMark; a fourth would be an indented code block, which
90
+ * this module deliberately does not track.
91
+ */
92
+ const FENCE_PATTERN = /^ {0,3}(?:```|~~~)/
93
+
94
+ const INDENT_PATTERN = /^[ \t]*/
95
+
96
+ const BLOCKQUOTE_PATTERN = /^(?:>[ \t]?)+/
97
+
98
+ const THEMATIC_BREAK_PATTERN =
99
+ /^(?:\*[ \t]*){3,}$|^(?:-[ \t]*){3,}$|^(?:_[ \t]*){3,}$/
100
+
101
+ const HEADING_PATTERN = /^#{1,6}[ \t]+/
102
+
103
+ const LIST_MARKER_PATTERN = /^(?:[-*+]|\d{1,9}[.)])[ \t]+/
104
+
105
+ const TASK_MARKER_PATTERN = /^\[[ xX]\][ \t]*/
106
+
107
+ /**
108
+ * Sticky, because every one of these is tried at a known index
109
+ * rather than searched for. A non-sticky `exec` would happily match
110
+ * an emphasis pair fifty characters further down the line and the
111
+ * scanner would swallow everything in between.
112
+ *
113
+ * Order is the whole grammar here, and two orderings are
114
+ * load-bearing:
115
+ *
116
+ * - **Escape first.** `\*not emphasis\*` has to lose to the
117
+ * backslash, or the editor colours text the renderer will not.
118
+ * - **Code before everything.** A code span suppresses every other
119
+ * construct inside it, which is what makes `` `**` `` render as
120
+ * two asterisks rather than an unterminated bold run.
121
+ * - **Image before link**, because `![alt](url)` starts with a
122
+ * valid link at index 1.
123
+ * - **Strong before emphasis**, because `**` starts with `*`.
124
+ */
125
+ const INLINE_PATTERNS = {
126
+ code: /(`+)([^\n]*?)\1/y,
127
+ emphasis: /([*_])(?=[^\s*_])([^\n*_]*?[^\s*_])\1/y,
128
+ escape: /\\([\\`*_{}[\]()#+\-.!>~|])/y,
129
+ image: /(!\[)([^\]\n]*)(\]\()([^)\s\n]*)(\))/y,
130
+ link: /(\[)([^\]\n]*)(\]\()([^)\s\n]*)(\))/y,
131
+ strikethrough: /(~~)(?=\S)([^\n]*?\S)\1/y,
132
+ strong: /(\*\*|__)(?=\S)([^\n]*?\S)\1/y,
133
+ } as const
134
+
135
+ const pushSpan = (
136
+ spans: MarkdownSpan[],
137
+ kind: MarkdownSpanKind,
138
+ text: string,
139
+ ) => {
140
+ if (text === "") {
141
+ return
142
+ }
143
+
144
+ const previous = spans.at(-1)
145
+
146
+ // Merged as they are pushed rather than in a second pass. The
147
+ // scanner emits one span per plain *character*, so without this a
148
+ // 200-character paragraph is 200 React elements and every
149
+ // keystroke reconciles all of them.
150
+ if (previous?.kind === kind) {
151
+ previous.text += text
152
+
153
+ return
154
+ }
155
+
156
+ spans.push({ kind, text })
157
+ }
158
+
159
+ /**
160
+ * The inline scanner.
161
+ *
162
+ * `baseKind` is what unmarked text becomes — `"plain"` in a
163
+ * paragraph, `"heading"` inside a heading — so a heading's words
164
+ * carry the heading colour while the `##` in front of them stays a
165
+ * marker.
166
+ */
167
+ export const toInlineSpans = (
168
+ text: string,
169
+ baseKind: MarkdownSpanKind = "plain",
170
+ ): MarkdownSpan[] => {
171
+ const spans: MarkdownSpan[] = []
172
+
173
+ let index = 0
174
+
175
+ while (index < text.length) {
176
+ INLINE_PATTERNS.escape.lastIndex = index
177
+
178
+ const escaped = INLINE_PATTERNS.escape.exec(text)
179
+
180
+ if (escaped) {
181
+ pushSpan(spans, "marker", "\\")
182
+
183
+ pushSpan(spans, baseKind, escaped[1] ?? "")
184
+
185
+ index += escaped[0].length
186
+
187
+ continue
188
+ }
189
+
190
+ INLINE_PATTERNS.code.lastIndex = index
191
+
192
+ const code = INLINE_PATTERNS.code.exec(text)
193
+
194
+ if (code) {
195
+ pushSpan(spans, "marker", code[1] ?? "")
196
+
197
+ pushSpan(spans, "code", code[2] ?? "")
198
+
199
+ pushSpan(spans, "marker", code[1] ?? "")
200
+
201
+ index += code[0].length
202
+
203
+ continue
204
+ }
205
+
206
+ INLINE_PATTERNS.image.lastIndex = index
207
+
208
+ const image = INLINE_PATTERNS.image.exec(text)
209
+
210
+ if (image) {
211
+ pushSpan(spans, "marker", image[1] ?? "")
212
+
213
+ pushSpan(spans, "link", image[2] ?? "")
214
+
215
+ pushSpan(spans, "marker", image[3] ?? "")
216
+
217
+ pushSpan(spans, "url", image[4] ?? "")
218
+
219
+ pushSpan(spans, "marker", image[5] ?? "")
220
+
221
+ index += image[0].length
222
+
223
+ continue
224
+ }
225
+
226
+ INLINE_PATTERNS.link.lastIndex = index
227
+
228
+ const link = INLINE_PATTERNS.link.exec(text)
229
+
230
+ if (link) {
231
+ pushSpan(spans, "marker", link[1] ?? "")
232
+
233
+ pushSpan(spans, "link", link[2] ?? "")
234
+
235
+ pushSpan(spans, "marker", link[3] ?? "")
236
+
237
+ pushSpan(spans, "url", link[4] ?? "")
238
+
239
+ pushSpan(spans, "marker", link[5] ?? "")
240
+
241
+ index += link[0].length
242
+
243
+ continue
244
+ }
245
+
246
+ INLINE_PATTERNS.strong.lastIndex = index
247
+
248
+ const strong = INLINE_PATTERNS.strong.exec(text)
249
+
250
+ if (strong) {
251
+ pushSpan(spans, "marker", strong[1] ?? "")
252
+
253
+ pushSpan(spans, "strong", strong[2] ?? "")
254
+
255
+ pushSpan(spans, "marker", strong[1] ?? "")
256
+
257
+ index += strong[0].length
258
+
259
+ continue
260
+ }
261
+
262
+ INLINE_PATTERNS.strikethrough.lastIndex = index
263
+
264
+ const struck = INLINE_PATTERNS.strikethrough.exec(text)
265
+
266
+ if (struck) {
267
+ pushSpan(spans, "marker", struck[1] ?? "")
268
+
269
+ pushSpan(spans, "strikethrough", struck[2] ?? "")
270
+
271
+ pushSpan(spans, "marker", struck[1] ?? "")
272
+
273
+ index += struck[0].length
274
+
275
+ continue
276
+ }
277
+
278
+ INLINE_PATTERNS.emphasis.lastIndex = index
279
+
280
+ const emphasised = INLINE_PATTERNS.emphasis.exec(text)
281
+
282
+ if (emphasised) {
283
+ pushSpan(spans, "marker", emphasised[1] ?? "")
284
+
285
+ pushSpan(spans, "emphasis", emphasised[2] ?? "")
286
+
287
+ pushSpan(spans, "marker", emphasised[1] ?? "")
288
+
289
+ index += emphasised[0].length
290
+
291
+ continue
292
+ }
293
+
294
+ pushSpan(spans, baseKind, text[index] ?? "")
295
+
296
+ index += 1
297
+ }
298
+
299
+ return spans
300
+ }
301
+
302
+ const toTableSpans = (text: string): MarkdownSpan[] => {
303
+ const spans: MarkdownSpan[] = []
304
+
305
+ for (const part of text.split(/(\|)/)) {
306
+ if (part === "|") {
307
+ pushSpan(spans, "marker", part)
308
+
309
+ continue
310
+ }
311
+
312
+ for (const span of toInlineSpans(part)) {
313
+ pushSpan(spans, span.kind, span.text)
314
+ }
315
+ }
316
+
317
+ return spans
318
+ }
319
+
320
+ const toBlockLine = (line: string): MarkdownLine => {
321
+ const spans: MarkdownSpan[] = []
322
+
323
+ const indent = INDENT_PATTERN.exec(line)?.[0] ?? ""
324
+
325
+ pushSpan(spans, "plain", indent)
326
+
327
+ let rest = line.slice(indent.length)
328
+
329
+ const quote = BLOCKQUOTE_PATTERN.exec(rest)?.[0]
330
+
331
+ if (quote !== undefined) {
332
+ pushSpan(spans, "marker", quote)
333
+
334
+ rest = rest.slice(quote.length)
335
+ }
336
+
337
+ if (THEMATIC_BREAK_PATTERN.test(rest)) {
338
+ pushSpan(spans, "marker", rest)
339
+
340
+ return { kind: "thematicBreak", spans }
341
+ }
342
+
343
+ const heading = HEADING_PATTERN.exec(rest)?.[0]
344
+
345
+ if (heading !== undefined) {
346
+ pushSpan(spans, "marker", heading)
347
+
348
+ for (const span of toInlineSpans(
349
+ rest.slice(heading.length),
350
+ "heading",
351
+ )) {
352
+ pushSpan(spans, span.kind, span.text)
353
+ }
354
+
355
+ return { kind: "heading", spans }
356
+ }
357
+
358
+ const listMarker = LIST_MARKER_PATTERN.exec(rest)?.[0]
359
+
360
+ if (listMarker !== undefined) {
361
+ pushSpan(spans, "marker", listMarker)
362
+
363
+ rest = rest.slice(listMarker.length)
364
+
365
+ const taskMarker = TASK_MARKER_PATTERN.exec(rest)?.[0]
366
+
367
+ if (taskMarker !== undefined) {
368
+ pushSpan(spans, "marker", taskMarker)
369
+
370
+ rest = rest.slice(taskMarker.length)
371
+ }
372
+
373
+ for (const span of toInlineSpans(rest)) {
374
+ pushSpan(spans, span.kind, span.text)
375
+ }
376
+
377
+ return { kind: "list", spans }
378
+ }
379
+
380
+ if (rest.startsWith("|")) {
381
+ for (const span of toTableSpans(rest)) {
382
+ pushSpan(spans, span.kind, span.text)
383
+ }
384
+
385
+ return { kind: "table", spans }
386
+ }
387
+
388
+ for (const span of toInlineSpans(rest)) {
389
+ pushSpan(spans, span.kind, span.text)
390
+ }
391
+
392
+ return {
393
+ kind: quote === undefined ? "paragraph" : "blockquote",
394
+ spans,
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The whole document, one entry per line.
400
+ *
401
+ * `text.split("\n")` on an empty string yields `[""]` — one empty
402
+ * line — which is right: an empty editor still has a line for the
403
+ * caret to sit on, and the painted layer still needs a row so the
404
+ * two layers agree about where line 0 is.
405
+ */
406
+ export const toMarkdownLines = (
407
+ text: string,
408
+ ): MarkdownLine[] => {
409
+ let isInsideFence = false
410
+
411
+ return text.split("\n").map((line) => {
412
+ if (FENCE_PATTERN.test(line)) {
413
+ isInsideFence = !isInsideFence
414
+
415
+ return {
416
+ kind: "code" as const,
417
+ spans: [{ kind: "marker" as const, text: line }],
418
+ }
419
+ }
420
+
421
+ if (isInsideFence) {
422
+ return {
423
+ kind: "code" as const,
424
+ spans:
425
+ line === ""
426
+ ? []
427
+ : [{ kind: "code" as const, text: line }],
428
+ }
429
+ }
430
+
431
+ return toBlockLine(line)
432
+ })
433
+ }
434
+
435
+ /**
436
+ * Which line the caret is on, from a character offset.
437
+ *
438
+ * The active line is the one construct in this editor that is
439
+ * *state* rather than syntax: its markers paint at full strength
440
+ * and every other line's fade back, which is the whole "raw syntax
441
+ * on the cursor's line" behaviour.
442
+ */
443
+ export const toLineIndex = (
444
+ text: string,
445
+ offset: number,
446
+ ): number => {
447
+ const clamped = Math.max(0, Math.min(offset, text.length))
448
+
449
+ let lineIndex = 0
450
+
451
+ for (let index = 0; index < clamped; index += 1) {
452
+ if (text[index] === "\n") {
453
+ lineIndex += 1
454
+ }
455
+ }
456
+
457
+ return lineIndex
458
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react"
1
+ import type { ReactNode, RefObject } from "react"
2
2
 
3
3
  import { OverlayPanel } from "../Overlay/OverlayPanel.tsx"
4
4
  import { toClassName } from "../toClassName.ts"
@@ -8,6 +8,10 @@ export type ModalProps = {
8
8
  "aria-labelledby"?: string
9
9
  children: ReactNode
10
10
  className?: string
11
+ /** Which element takes the caret when the modal opens. Without
12
+ * it the focus manager takes the first tabbable element — the
13
+ * Close button on anything with chrome. */
14
+ initialFocus?: RefObject<HTMLElement | null>
11
15
  /** Escape and a backdrop press both close. */
12
16
  isDismissable?: boolean
13
17
  isVisible: boolean
@@ -59,6 +63,7 @@ export const Modal = ({
59
63
  "aria-labelledby": ariaLabelledBy,
60
64
  children,
61
65
  className,
66
+ initialFocus,
62
67
  isDismissable = true,
63
68
  isVisible,
64
69
  onClose,
@@ -68,6 +73,7 @@ export const Modal = ({
68
73
  aria-label={ariaLabel}
69
74
  aria-labelledby={ariaLabelledBy}
70
75
  className={toClassName(MODAL_BOX_CLASS, className)}
76
+ initialFocus={initialFocus}
71
77
  isDismissable={isDismissable}
72
78
  isVisible={isVisible}
73
79
  onClose={onClose}