@elabs-ai/components-editor 4.0.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Unit tests for the slash-menu shortcut helpers.
3
+ * Pure functions — no React, no DOM engine needed beyond vitest's node env.
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mock monaco-editor — the shortcut module reads KeyMod/KeyCode constants at
9
+ // import time so we must hoist this before the import.
10
+ // ---------------------------------------------------------------------------
11
+ vi.mock("monaco-editor", () => {
12
+ // Use real-ish numeric values that don't collide so we can assert bitwise ops.
13
+ const KeyCode = {
14
+ Slash: 85,
15
+ Backspace: 1,
16
+ Delete: 2,
17
+ Escape: 9,
18
+ Enter: 3,
19
+ Tab: 2048,
20
+ UpArrow: 16,
21
+ DownArrow: 17,
22
+ LeftArrow: 15,
23
+ RightArrow: 18,
24
+ // Letters KeyA..KeyZ
25
+ KeyA: 31,
26
+ KeyB: 32,
27
+ KeyC: 33,
28
+ KeyD: 34,
29
+ KeyE: 35,
30
+ KeyF: 36,
31
+ KeyG: 37,
32
+ KeyH: 38,
33
+ KeyI: 39,
34
+ KeyJ: 40,
35
+ KeyK: 41,
36
+ KeyL: 42,
37
+ KeyM: 43,
38
+ KeyN: 44,
39
+ KeyO: 45,
40
+ KeyP: 46,
41
+ KeyQ: 47,
42
+ KeyR: 48,
43
+ KeyS: 49,
44
+ KeyT: 50,
45
+ KeyU: 51,
46
+ KeyV: 52,
47
+ KeyW: 53,
48
+ KeyX: 54,
49
+ KeyY: 55,
50
+ KeyZ: 56,
51
+ } as const;
52
+
53
+ const KeyMod = {
54
+ CtrlCmd: 2048,
55
+ Shift: 1024,
56
+ Alt: 512,
57
+ WinCtrl: 256,
58
+ } as const;
59
+
60
+ return { KeyCode, KeyMod };
61
+ });
62
+
63
+ import { DEFAULT_SLASH_SHORTCUT, matchesKeyboardEvent } from "./shortcut";
64
+ // parseShortcut moved to the Monaco half (keeps ./shortcut Monaco-free); the
65
+ // monaco mock above backs it.
66
+ import { parseShortcut } from "./shortcut-monaco";
67
+
68
+ // Pull the mocked constants for assertions.
69
+ const { KeyCode, KeyMod } = await import("monaco-editor");
70
+
71
+ describe("DEFAULT_SLASH_SHORTCUT", () => {
72
+ it("is Mod-Shift-O (NOT Mod-/, which collides with Monaco's Toggle Line Comment)", () => {
73
+ expect(DEFAULT_SLASH_SHORTCUT).toBe("Mod-Shift-O");
74
+ });
75
+ });
76
+
77
+ describe("parseShortcut", () => {
78
+ it('parses "Mod-/" → CtrlCmd | Slash', () => {
79
+ expect(parseShortcut("Mod-/")).toBe(KeyMod.CtrlCmd | KeyCode.Slash);
80
+ });
81
+
82
+ it('parses "Mod-Shift-K" → CtrlCmd | Shift | KeyK', () => {
83
+ expect(parseShortcut("Mod-Shift-K")).toBe(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyK);
84
+ });
85
+
86
+ it("parses letter keys case-insensitively", () => {
87
+ expect(parseShortcut("Mod-k")).toBe(parseShortcut("Mod-K"));
88
+ });
89
+
90
+ it('parses "Alt-/" → Alt | Slash', () => {
91
+ expect(parseShortcut("Alt-/")).toBe(KeyMod.Alt | KeyCode.Slash);
92
+ });
93
+
94
+ it("throws for an unrecognized final key", () => {
95
+ expect(() => parseShortcut("Mod-~")).toThrow();
96
+ });
97
+ });
98
+
99
+ describe("matchesKeyboardEvent", () => {
100
+ const makeEvent = (overrides: Partial<KeyboardEvent>): KeyboardEvent =>
101
+ ({
102
+ metaKey: false,
103
+ ctrlKey: false,
104
+ shiftKey: false,
105
+ altKey: false,
106
+ key: "",
107
+ ...overrides,
108
+ }) as KeyboardEvent;
109
+
110
+ describe('shortcut "Mod-/"', () => {
111
+ it("matches when metaKey=true and key='/'", () => {
112
+ expect(matchesKeyboardEvent("Mod-/", makeEvent({ metaKey: true, key: "/" }))).toBe(true);
113
+ });
114
+
115
+ it("matches when ctrlKey=true and key='/'", () => {
116
+ expect(matchesKeyboardEvent("Mod-/", makeEvent({ ctrlKey: true, key: "/" }))).toBe(true);
117
+ });
118
+
119
+ it("does NOT match plain '/' (no modifier)", () => {
120
+ expect(matchesKeyboardEvent("Mod-/", makeEvent({ key: "/" }))).toBe(false);
121
+ });
122
+
123
+ it("does NOT match wrong modifier (altKey only)", () => {
124
+ expect(matchesKeyboardEvent("Mod-/", makeEvent({ altKey: true, key: "/" }))).toBe(false);
125
+ });
126
+
127
+ it("does NOT match wrong key with correct modifier", () => {
128
+ expect(matchesKeyboardEvent("Mod-/", makeEvent({ metaKey: true, key: "k" }))).toBe(false);
129
+ });
130
+
131
+ it("does NOT match when an unexpected extra modifier is pressed (shift)", () => {
132
+ expect(
133
+ matchesKeyboardEvent("Mod-/", makeEvent({ metaKey: true, shiftKey: true, key: "/" })),
134
+ ).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe('shortcut "Mod-Shift-K"', () => {
139
+ it("matches Cmd+Shift+k (metaKey + shiftKey)", () => {
140
+ expect(
141
+ matchesKeyboardEvent("Mod-Shift-K", makeEvent({ metaKey: true, shiftKey: true, key: "k" })),
142
+ ).toBe(true);
143
+ });
144
+
145
+ it("matches case-insensitively for the key", () => {
146
+ expect(
147
+ matchesKeyboardEvent("Mod-Shift-K", makeEvent({ metaKey: true, shiftKey: true, key: "K" })),
148
+ ).toBe(true);
149
+ });
150
+
151
+ it("does NOT match without shift", () => {
152
+ expect(matchesKeyboardEvent("Mod-Shift-K", makeEvent({ metaKey: true, key: "k" }))).toBe(
153
+ false,
154
+ );
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shortcut helpers for the cross-pane slash menu (#271) — the PURE half.
3
+ *
4
+ * No React, no Milkdown, **no Monaco**. This module is imported by
5
+ * `brand-slash-plugin.ts` (and therefore by the Milkdown `MarkdownEditor` import
6
+ * graph), so it must NOT pull `monaco-editor` — doing so drags the Monaco runtime
7
+ * (and its jsdom-hostile clipboard module) into every WYSIWYG test. The
8
+ * Monaco-keybinding conversion lives in the sibling `./shortcut-monaco.ts`, which
9
+ * only the Monaco-side surfaces (the workspace) import.
10
+ *
11
+ * INTERNAL — not exported from package barrels.
12
+ */
13
+
14
+ /**
15
+ * The default keyboard shortcut for the slash command menu.
16
+ * `Mod` = Cmd on macOS, Ctrl elsewhere (Monaco's `CtrlCmd`).
17
+ *
18
+ * NOTE: this is NOT `Mod-/` — Monaco binds `Mod-/` to "Toggle Line Comment" by
19
+ * default, which eats the keystroke before the slash action sees it (the source
20
+ * pane's primary trigger is typing `/`; this hotkey is the secondary path). `Mod-Shift-O`
21
+ * is free in Chrome/macOS; it DOES collide with the bookmarks shortcut in Firefox
22
+ * and on Windows/Linux Chrome, so consumers shipping cross-browser should override
23
+ * `slashMenu.shortcut`. The conflict-free trigger is typing `/`.
24
+ */
25
+ export const DEFAULT_SLASH_SHORTCUT = "Mod-Shift-O";
26
+
27
+ /**
28
+ * Returns `true` when a DOM `KeyboardEvent` matches a shortcut string.
29
+ *
30
+ * - `Mod` matches `event.metaKey` (macOS) OR `event.ctrlKey` (other platforms).
31
+ * - `Shift` / `Alt` match the corresponding event fields.
32
+ * - The final key matches `event.key` (case-insensitive); `"/"` matches
33
+ * `event.key === "/"`.
34
+ *
35
+ * Used by the WYSIWYG plugin's `handleKeyDown` (which receives DOM events) to
36
+ * decide whether to open the menu — pure, so it never pulls the monaco namespace.
37
+ */
38
+ export function matchesKeyboardEvent(shortcut: string, event: KeyboardEvent): boolean {
39
+ const parts = shortcut.split("-");
40
+ const keyPart = parts[parts.length - 1] ?? "";
41
+ const modifiers = new Set(parts.slice(0, -1).map((m) => m.toLowerCase()));
42
+
43
+ // Check modifier flags.
44
+ const wantsMod = modifiers.has("mod");
45
+ const wantsShift = modifiers.has("shift");
46
+ const wantsAlt = modifiers.has("alt");
47
+
48
+ if (wantsMod && !(event.metaKey || event.ctrlKey)) return false;
49
+ if (!wantsMod && (event.metaKey || event.ctrlKey)) return false;
50
+ if (wantsShift && !event.shiftKey) return false;
51
+ if (!wantsShift && event.shiftKey) return false;
52
+ if (wantsAlt && !event.altKey) return false;
53
+ if (!wantsAlt && event.altKey) return false;
54
+
55
+ // Check the key itself (case-insensitive).
56
+ return event.key.toLowerCase() === keyPart.toLowerCase();
57
+ }
@@ -0,0 +1,139 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { useState } from "react";
4
+
5
+ import { MarkdownEditor } from "../markdown-editor";
6
+ import { BRAND_SLASH_COMMANDS, filterSlashCommands } from "./brand-slash-commands";
7
+ import { SlashMenu } from "./slash-menu";
8
+
9
+ /**
10
+ * The `/` command menu for the WYSIWYG markdown editor. Typing `/` at a block
11
+ * start (or after whitespace) opens a branded popup that inserts the brand `:::`
12
+ * directives (card / callout / metric / timeline) + basic blocks live at the
13
+ * caret. Opt in via the `slashMenu` prop on `MarkdownEditor` /
14
+ * `MarkdownWorkspace` (default `true`).
15
+ *
16
+ * The popup itself is rendered here standalone (the `SlashMenu` stories) so its
17
+ * branding + a11y can be verified directly across all three themes; the in-editor
18
+ * stories exercise the integration (mount + the `slashMenu={false}` no-op). The
19
+ * live keyboard/insert UX is best verified in a real browser — ProseMirror
20
+ * keystroke dispatch can't be fully simulated in jsdom.
21
+ */
22
+ const meta = {
23
+ title: "Editor/MarkdownEditor/Slash menu",
24
+ component: SlashMenu,
25
+ tags: ["autodocs"],
26
+ parameters: {
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "Branded `/` command menu for the WYSIWYG editor. Inserts brand `:::` " +
31
+ "directives + basic blocks at the caret. Token-driven `listbox`/`option` " +
32
+ "list; the editor `textbox` owns `aria-activedescendant`.",
33
+ },
34
+ },
35
+ },
36
+ } satisfies Meta<typeof SlashMenu>;
37
+
38
+ export default meta;
39
+ type Story = StoryObj<typeof meta>;
40
+
41
+ /** The popup standalone, with the first command highlighted (default state). */
42
+ export const Default: Story = {
43
+ args: {
44
+ commands: BRAND_SLASH_COMMANDS,
45
+ activeId: BRAND_SLASH_COMMANDS[0]?.id,
46
+ onSelect: () => {},
47
+ },
48
+ render: (args) => (
49
+ <div className="p-6">
50
+ <SlashMenu {...args} />
51
+ </div>
52
+ ),
53
+ play: async ({ canvasElement }) => {
54
+ const canvas = within(canvasElement);
55
+ // The popup is a labelled listbox of options grouped by section.
56
+ await expect(canvas.getByRole("listbox", { name: "Insert block" })).toBeVisible();
57
+ await expect(canvas.getByRole("option", { name: /Card/ })).toBeVisible();
58
+ // The first option is selected by default.
59
+ await expect(canvas.getByRole("option", { name: /Card/ })).toHaveAttribute(
60
+ "aria-selected",
61
+ "true",
62
+ );
63
+ },
64
+ };
65
+
66
+ /** Filtered to a query (`/ca`) — Card + Callout match by label, the Brand group leads. */
67
+ export const Filtered: Story = {
68
+ args: {
69
+ commands: filterSlashCommands(BRAND_SLASH_COMMANDS, "ca"),
70
+ onSelect: () => {},
71
+ },
72
+ render: function FilteredStory(args) {
73
+ const [activeId, setActiveId] = useState(args.commands[0]?.id);
74
+ return (
75
+ <div className="p-6">
76
+ <SlashMenu {...args} activeId={activeId} onSelect={(c) => setActiveId(c.id)} />
77
+ </div>
78
+ );
79
+ },
80
+ };
81
+
82
+ /** The empty state when nothing matches the query. */
83
+ export const Empty: Story = {
84
+ args: {
85
+ commands: [],
86
+ onSelect: () => {},
87
+ },
88
+ render: (args) => (
89
+ <div className="p-6">
90
+ <SlashMenu {...args} />
91
+ </div>
92
+ ),
93
+ play: async ({ canvasElement }) => {
94
+ const canvas = within(canvasElement);
95
+ await expect(canvas.getByText("No matching blocks")).toBeVisible();
96
+ },
97
+ };
98
+
99
+ const SAMPLE = `# Release notes
100
+
101
+ Type "/" on a new line to insert a brand block (card, callout, metric, timeline)
102
+ or a basic block.
103
+ `;
104
+
105
+ /**
106
+ * The slash menu wired into the live editor. Place the caret on an empty line and
107
+ * type `/` to open the menu. (The play test only asserts the editor mounts — drive
108
+ * the keyboard/insert flow in a real browser.)
109
+ */
110
+ export const InEditor: StoryObj<typeof MarkdownEditor> = {
111
+ name: "In editor (slashMenu enabled)",
112
+ render: () => (
113
+ <div className="mx-auto max-w-3xl p-6">
114
+ <MarkdownEditor defaultValue={SAMPLE} slashMenu />
115
+ </div>
116
+ ),
117
+ play: async ({ canvasElement }) => {
118
+ const canvas = within(canvasElement);
119
+ await expect(
120
+ await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
121
+ ).toBeVisible();
122
+ },
123
+ };
124
+
125
+ /** `slashMenu={false}` disables the menu (the editor renders normally). */
126
+ export const Disabled: StoryObj<typeof MarkdownEditor> = {
127
+ name: "In editor (slashMenu disabled)",
128
+ render: () => (
129
+ <div className="mx-auto max-w-3xl p-6">
130
+ <MarkdownEditor defaultValue={SAMPLE} slashMenu={false} />
131
+ </div>
132
+ ),
133
+ play: async ({ canvasElement }) => {
134
+ const canvas = within(canvasElement);
135
+ await expect(
136
+ await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
137
+ ).toBeVisible();
138
+ },
139
+ };
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SlashMenu — the branded popup the slash plugin renders at the caret.
5
+ *
6
+ * Presentational + controlled: the ProseMirror plugin owns the keyboard
7
+ * (↑/↓/Enter/Esc) and the open/query/active state, so this component just renders
8
+ * the filtered, grouped commands and exposes `onSelect`. It is a token-styled
9
+ * `listbox`/`option` list (NOT `@elabs-ai/components-ui` `Command`/cmdk): a cmdk instance would
10
+ * fight the editor for DOM focus and arrow-key handling, since the caret must stay
11
+ * in the ProseMirror `textbox` while the user types `/query`. The plugin keeps the
12
+ * active option in view and wires `aria-activedescendant` on the editor surface.
13
+ *
14
+ * Semantic tokens only; matches the `Command` popover look (bg-popover, accent
15
+ * selection, muted group headings).
16
+ */
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+ import { forwardRef, type HTMLAttributes } from "react";
19
+
20
+ import { groupSlashCommands, type SlashCommand } from "./brand-slash-commands";
21
+
22
+ export interface SlashMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
23
+ /** The (already filtered) commands to show. */
24
+ commands: SlashCommand[];
25
+ /** The id of the active (highlighted) command, for `aria-selected`. */
26
+ activeId?: string;
27
+ /** Called when a command is chosen (click or via the plugin's Enter). */
28
+ onSelect: (command: SlashCommand) => void;
29
+ /** DOM id prefix so each option id is stable + unique (matches `aria-activedescendant`). */
30
+ idPrefix?: string;
31
+ /** Shown when the query matches nothing. */
32
+ emptyLabel?: string;
33
+ }
34
+
35
+ /** Build the stable DOM id for a command's option element. */
36
+ export function slashOptionId(idPrefix: string, commandId: string): string {
37
+ return `${idPrefix}-${commandId}`;
38
+ }
39
+
40
+ export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(function SlashMenu(
41
+ {
42
+ commands,
43
+ activeId,
44
+ onSelect,
45
+ idPrefix = "brand-slash",
46
+ emptyLabel = "No matching blocks",
47
+ className,
48
+ ...props
49
+ },
50
+ ref,
51
+ ) {
52
+ const groups = groupSlashCommands(commands);
53
+
54
+ return (
55
+ <div
56
+ ref={ref}
57
+ // The editor surface carries `role="textbox"`; this list is its popup. The
58
+ // plugin sets `aria-controls`/`aria-activedescendant` on the textbox.
59
+ role="listbox"
60
+ aria-label="Insert block"
61
+ className={cn(
62
+ "max-h-[min(320px,60vh)] w-72 overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
63
+ className,
64
+ )}
65
+ {...props}
66
+ >
67
+ {commands.length === 0 ? (
68
+ <div className="px-2 py-6 text-center text-caption text-muted-foreground">{emptyLabel}</div>
69
+ ) : (
70
+ groups.map(({ group, commands: groupCommands }) => (
71
+ <div key={group} role="group" aria-label={group} className="overflow-hidden p-1">
72
+ <div className="px-2 py-1.5 text-meta font-medium text-muted-foreground">{group}</div>
73
+ {groupCommands.map((command) => {
74
+ const selected = command.id === activeId;
75
+ return (
76
+ <div
77
+ key={command.id}
78
+ id={slashOptionId(idPrefix, command.id)}
79
+ role="option"
80
+ aria-selected={selected}
81
+ data-selected={selected ? "true" : undefined}
82
+ // The editor keeps focus, so select on mousedown (before the
83
+ // editor would steal focus back) and don't let the click move
84
+ // the selection out of the document.
85
+ onMouseDown={(e) => {
86
+ e.preventDefault();
87
+ onSelect(command);
88
+ }}
89
+ className={cn(
90
+ "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast",
91
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
92
+ )}
93
+ >
94
+ {command.icon ? (
95
+ <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
96
+ {command.icon}
97
+ </span>
98
+ ) : null}
99
+ <span className="flex min-w-0 flex-col">
100
+ <span className="truncate">{command.label}</span>
101
+ {command.description ? (
102
+ <span className="truncate text-meta text-muted-foreground">
103
+ {command.description}
104
+ </span>
105
+ ) : null}
106
+ </span>
107
+ </div>
108
+ );
109
+ })}
110
+ </div>
111
+ ))
112
+ )}
113
+ </div>
114
+ );
115
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Locking tests for slash-menu scroll-to-active (A2). The scroll + aria wiring
3
+ * already ships (slash-widget.tsx), so these guard it from silent removal: the
4
+ * popup must be a capped, scrollable listbox whose options carry stable ids (the
5
+ * scroll / `aria-activedescendant` target), and the widget must scroll the active
6
+ * option into view as ↑/↓ moves it. The live keyboard UX is covered in the
7
+ * browser by slash-menu.stories.tsx.
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import { readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { render, screen } from "@testing-library/react";
13
+
14
+ import { SlashMenu, slashOptionId } from "./slash-menu";
15
+ import { BRAND_SLASH_COMMANDS } from "./brand-slash-commands";
16
+
17
+ describe("slash menu scroll-to-active (A2)", () => {
18
+ it("renders a capped, scrollable listbox so the active row can scroll into view", () => {
19
+ render(<SlashMenu commands={BRAND_SLASH_COMMANDS} activeId="brand-card" onSelect={() => {}} />);
20
+ const list = screen.getByRole("listbox");
21
+ expect(list.className).toMatch(/overflow-y-auto/);
22
+ expect(list.className).toMatch(/max-h-/);
23
+ });
24
+
25
+ it("gives each option a stable id (the scroll + aria-activedescendant target)", () => {
26
+ render(
27
+ <SlashMenu
28
+ commands={BRAND_SLASH_COMMANDS}
29
+ activeId="brand-card"
30
+ onSelect={() => {}}
31
+ idPrefix="x"
32
+ />,
33
+ );
34
+ const opt = document.getElementById(slashOptionId("x", "brand-card"));
35
+ expect(opt).not.toBeNull();
36
+ expect(opt?.getAttribute("role")).toBe("option");
37
+ expect(opt?.getAttribute("aria-selected")).toBe("true");
38
+ });
39
+
40
+ it("the widget scrolls the active option into view and mirrors aria-activedescendant", () => {
41
+ // This behavior lives in the widget's layout effects, which need a live
42
+ // ProseMirror view to exercise — lock it at the source so it can't regress.
43
+ const src = readFileSync(
44
+ resolve(process.cwd(), "src/markdown-editor/slash/slash-widget.tsx"),
45
+ "utf8",
46
+ );
47
+ expect(src).toMatch(/scrollIntoView\(\{\s*block:\s*"nearest"\s*\}\)/);
48
+ expect(src).toMatch(/aria-activedescendant/);
49
+ // The scroll effect re-runs as ↑/↓ changes the active row.
50
+ expect(src).toMatch(/\[activeId\]/);
51
+ });
52
+ });
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SlashWidget — the React widget the slash plugin mounts at the caret.
5
+ *
6
+ * The adapter renders this (no props) inside a ProseMirror widget decoration; it
7
+ * reads the live `EditorView` from `useWidgetViewContext()`, pulls the slash
8
+ * plugin state, and renders the branded `<SlashMenu>` floating just below the
9
+ * caret. Clicking an option runs the command through the shared
10
+ * {@link SlashController} (same path as the plugin's Enter handler), so the mouse
11
+ * and keyboard insert identically.
12
+ *
13
+ * Built via `createSlashWidget(controller)` so the component closes over the
14
+ * controller (commands + Milkdown `Ctx` getter) — no module-level mutable state,
15
+ * no circular import with the plugin.
16
+ */
17
+ import type { ReactWidgetViewComponent } from "@prosemirror-adapter/react";
18
+ import { useWidgetViewContext } from "@prosemirror-adapter/react";
19
+ import { useLayoutEffect, useRef } from "react";
20
+
21
+ import { filterSlashCommands, type SlashCommand } from "./brand-slash-commands";
22
+ import { SlashMenu, slashOptionId } from "./slash-menu";
23
+ import {
24
+ runSlashCommand,
25
+ slashPluginKey,
26
+ type SlashController,
27
+ type SlashPluginState,
28
+ } from "./brand-slash-plugin";
29
+
30
+ const ID_PREFIX = "brand-slash";
31
+
32
+ /** Build the widget component bound to a controller. */
33
+ export function createSlashWidget(controller: SlashController): ReactWidgetViewComponent {
34
+ function SlashWidget() {
35
+ const { view } = useWidgetViewContext();
36
+ const wrapperRef = useRef<HTMLSpanElement>(null);
37
+
38
+ const state = slashPluginKey.getState(view.state) as SlashPluginState | undefined;
39
+ const query = state?.query ?? "";
40
+ const filtered = filterSlashCommands(controller.commands, query);
41
+ const activeIndex = state ? Math.min(state.index, Math.max(0, filtered.length - 1)) : 0;
42
+ const active = filtered[activeIndex];
43
+ const activeId = active?.id;
44
+
45
+ // Mirror the active option onto the editor's `textbox` for AT
46
+ // (`aria-activedescendant` + `aria-controls`), then clean up on unmount.
47
+ useLayoutEffect(() => {
48
+ const dom = view.dom as HTMLElement;
49
+ const listEl = wrapperRef.current?.querySelector<HTMLElement>('[role="listbox"]');
50
+ if (listEl && !listEl.id) listEl.id = `${ID_PREFIX}-listbox`;
51
+ dom.setAttribute("aria-expanded", "true");
52
+ if (listEl) dom.setAttribute("aria-controls", listEl.id);
53
+ if (activeId) dom.setAttribute("aria-activedescendant", slashOptionId(ID_PREFIX, activeId));
54
+ else dom.removeAttribute("aria-activedescendant");
55
+ return () => {
56
+ dom.removeAttribute("aria-expanded");
57
+ dom.removeAttribute("aria-controls");
58
+ dom.removeAttribute("aria-activedescendant");
59
+ };
60
+ }, [view, activeId]);
61
+
62
+ // Keep the highlighted option scrolled into view as ↑/↓ moves it.
63
+ useLayoutEffect(() => {
64
+ if (!activeId) return;
65
+ const el = wrapperRef.current?.querySelector<HTMLElement>(
66
+ `#${CSS.escape(slashOptionId(ID_PREFIX, activeId))}`,
67
+ );
68
+ el?.scrollIntoView({ block: "nearest" });
69
+ }, [activeId]);
70
+
71
+ const onSelect = (command: SlashCommand) => {
72
+ if (!state) return;
73
+ runSlashCommand(view, controller, state, command);
74
+ };
75
+
76
+ return (
77
+ // The widget anchor is zero-width inline; the menu floats below the caret.
78
+ <span
79
+ ref={wrapperRef}
80
+ contentEditable={false}
81
+ // Keep the widget out of the accessibility tree as a node — the listbox
82
+ // inside carries the semantics, and the editor textbox owns the relationship.
83
+ className="brand-slash-anchor relative inline-block h-0 w-0 align-baseline"
84
+ >
85
+ <span className="absolute left-0 top-1 z-50 block">
86
+ <SlashMenu
87
+ commands={filtered}
88
+ activeId={activeId}
89
+ onSelect={onSelect}
90
+ idPrefix={ID_PREFIX}
91
+ />
92
+ </span>
93
+ </span>
94
+ );
95
+ }
96
+ return SlashWidget;
97
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { slashTriggerRange } from "./source-slash-trigger";
4
+
5
+ describe("slashTriggerRange", () => {
6
+ it("triggers at the start of a line (column 1)", () => {
7
+ expect(slashTriggerRange(3, "/", 1)).toEqual({
8
+ startLineNumber: 3,
9
+ startColumn: 1,
10
+ endLineNumber: 3,
11
+ endColumn: 2,
12
+ });
13
+ });
14
+
15
+ it("triggers right after whitespace", () => {
16
+ // "hello /" → the "/" is at column 7, preceded by a space.
17
+ expect(slashTriggerRange(1, "hello /", 7)).toEqual({
18
+ startLineNumber: 1,
19
+ startColumn: 7,
20
+ endLineNumber: 1,
21
+ endColumn: 8,
22
+ });
23
+ });
24
+
25
+ it("does NOT trigger inside a word", () => {
26
+ // "ab/" → the "/" is at column 3, preceded by "b".
27
+ expect(slashTriggerRange(1, "ab/", 3)).toBeNull();
28
+ });
29
+
30
+ it("does NOT trigger inside a path / URL", () => {
31
+ expect(slashTriggerRange(1, "http://", 6)).toBeNull(); // preceded by ":"
32
+ expect(slashTriggerRange(1, "a/b/", 4)).toBeNull(); // preceded by "b"
33
+ });
34
+
35
+ it("returns null when the column does not actually hold a slash", () => {
36
+ expect(slashTriggerRange(1, "hello", 3)).toBeNull();
37
+ });
38
+
39
+ it("treats a tab as whitespace", () => {
40
+ expect(slashTriggerRange(1, "\t/", 2)).not.toBeNull();
41
+ });
42
+ });