@elabs-ai/components-editor 4.0.0 → 4.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 (77) hide show
  1. package/README.md +6 -7
  2. package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
  3. package/dist/chunk-FO5S3YZM.js.map +1 -0
  4. package/dist/index.css +4 -2
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.ts +5 -4
  7. package/dist/index.js +112 -57
  8. package/dist/index.js.map +1 -1
  9. package/dist/markdown/index.css +4 -2
  10. package/dist/markdown/index.css.map +1 -1
  11. package/dist/markdown/index.d.ts +2 -2
  12. package/dist/markdown/index.js +268 -191
  13. package/dist/markdown/index.js.map +1 -1
  14. package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
  15. package/dist/monaco.d.ts +2 -0
  16. package/dist/monaco.js +9 -0
  17. package/dist/monaco.js.map +1 -0
  18. package/package.json +10 -6
  19. package/src/ai-objects/decision-card.tsx +15 -9
  20. package/src/ai-objects/entity.tsx +1 -1
  21. package/src/ai-objects/knowledge-card.tsx +18 -9
  22. package/src/barrel-monaco-lazy.test.ts +50 -0
  23. package/src/calc-block/calc-block.tsx +10 -4
  24. package/src/calc-block/calc-editor.css +1 -1
  25. package/src/code-editor/code-editor.stories.tsx +76 -1
  26. package/src/code-editor/code-editor.test.tsx +141 -14
  27. package/src/code-editor/code-editor.tsx +167 -36
  28. package/src/code-workspace/code-workspace.test.tsx +113 -11
  29. package/src/code-workspace/code-workspace.tsx +73 -20
  30. package/src/copy-button/copy-button.tsx +6 -3
  31. package/src/diff-editor/diff-editor.stories.tsx +9 -1
  32. package/src/diff-editor/diff-editor.test.tsx +9 -3
  33. package/src/diff-editor/diff-editor.tsx +47 -23
  34. package/src/editor-toolbar/editor-toolbar.tsx +8 -2
  35. package/src/index.ts +4 -3
  36. package/src/lib/monaco-deep-imports.d.ts +59 -0
  37. package/src/lib/monaco-theme-bridge.test.ts +307 -0
  38. package/src/lib/monaco-theme-bridge.ts +154 -10
  39. package/src/markdown-academic/citations.tsx +14 -7
  40. package/src/markdown-academic/footnotes.tsx +2 -2
  41. package/src/markdown-academic/math.tsx +7 -4
  42. package/src/markdown-academic/toc.tsx +1 -1
  43. package/src/markdown-editor/completions/completions-menu.tsx +5 -2
  44. package/src/markdown-editor/directive-views.tsx +35 -24
  45. package/src/markdown-editor/markdown-editor.css +26 -3
  46. package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
  47. package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
  48. package/src/markdown-editor/markdown-editor.tsx +8 -5
  49. package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
  50. package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
  51. package/src/markdown-editor/paste-embed.ts +2 -1
  52. package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
  53. package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
  54. package/src/markdown-editor/slash/slash-menu.tsx +18 -3
  55. package/src/markdown-editor/table-view.tsx +31 -17
  56. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
  57. package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
  58. package/src/markdown-iteration/template-dialog.tsx +25 -7
  59. package/src/markdown-outline/document-outline.stories.tsx +1 -1
  60. package/src/markdown-outline/document-outline.tsx +7 -3
  61. package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
  62. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
  63. package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
  64. package/src/markdown-preview/markdown-preview.test.tsx +20 -3
  65. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
  66. package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
  67. package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
  68. package/src/markdown-workspace/markdown-workspace.tsx +6 -3
  69. package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
  70. package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
  71. package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
  72. package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
  73. package/src/monaco.ts +21 -0
  74. package/src/prose/prose.stories.tsx +3 -0
  75. package/src/prose/prose.test.ts +54 -0
  76. package/src/prose/prose.tsx +7 -0
  77. package/dist/chunk-LBC5VJBD.js.map +0 -1
@@ -14,7 +14,14 @@ const h = vi.hoisted(() => {
14
14
  setValue: vi.fn(),
15
15
  getModel: vi.fn(() => ({
16
16
  getValueInRange: vi.fn(() => "selected"),
17
+ // Deliberately reports the SAME content the controlled `value` prop
18
+ // carries in this suite's fixtures, so the controlled-value-sync
19
+ // effect (added for the executeEdits-over-setValue fix) is a no-op
20
+ // here and doesn't perturb these unrelated tab/selection assertions.
21
+ getValue: vi.fn(() => "AAA"),
22
+ getPositionAt: vi.fn((offset: number) => ({ lineNumber: 1, column: offset })),
17
23
  })),
24
+ setModel: vi.fn(),
18
25
  getSelection: vi.fn(() => ({ isEmpty: () => false })),
19
26
  executeEdits: vi.fn(),
20
27
  pushUndoStop: vi.fn(),
@@ -32,7 +39,11 @@ const h = vi.hoisted(() => {
32
39
  selectionHandlers,
33
40
  selectionDisposable,
34
41
  create: vi.fn(() => editor),
35
- createModel: vi.fn(() => ({ dispose: vi.fn() })),
42
+ createModel: vi.fn((value: string) => ({
43
+ dispose: vi.fn(),
44
+ getValue: vi.fn(() => value),
45
+ getPositionAt: vi.fn((offset: number) => ({ lineNumber: 1, column: offset })),
46
+ })),
36
47
  };
37
48
  });
38
49
 
@@ -45,6 +56,9 @@ vi.mock("monaco-editor", () => ({
45
56
  setTheme: vi.fn(),
46
57
  },
47
58
  Uri: { parse: (s: string) => ({ toString: () => s }) },
59
+ Range: {
60
+ fromPositions: (start: unknown, end: unknown) => ({ start, end }),
61
+ },
48
62
  }));
49
63
 
50
64
  import { createRef } from "react";
@@ -55,20 +69,28 @@ const FILES: EditorFile[] = [
55
69
  { path: "b.json", value: "{}" },
56
70
  ];
57
71
 
72
+ // CodeEditor's underlying engine now loads via a dynamic `import("monaco-editor")`
73
+ // (kept lazy so the barrel never evaluates Monaco just for `CopyButton`/
74
+ // `EDITOR_LANGUAGES` — see barrel-monaco-lazy.test.ts). Flush that microtask
75
+ // before asserting on anything the mount effect populates.
76
+ const flush = () => act(async () => {});
77
+
58
78
  beforeEach(() => vi.clearAllMocks());
59
79
  afterEach(cleanup);
60
80
 
61
81
  describe("CodeWorkspace — tabs", () => {
62
- it("renders a tab per file and opens the first file with its inferred language", () => {
82
+ it("renders a tab per file and opens the first file with its inferred language", async () => {
63
83
  render(<CodeWorkspace files={FILES} />);
64
84
  expect(screen.getByRole("tab", { name: "a.ts" })).toBeInTheDocument();
65
85
  expect(screen.getByRole("tab", { name: "b.json" })).toBeInTheDocument();
86
+ await flush();
66
87
  expect(h.createModel).toHaveBeenCalledWith("AAA", "typescript", expect.anything());
67
88
  });
68
89
 
69
90
  it("switches the editor to the file whose tab is activated", async () => {
70
91
  const onActivePathChange = vi.fn();
71
92
  render(<CodeWorkspace files={FILES} onActivePathChange={onActivePathChange} />);
93
+ await flush();
72
94
  await userEvent.click(screen.getByRole("tab", { name: "b.json" }));
73
95
  expect(onActivePathChange).toHaveBeenCalledWith("b.json");
74
96
  expect(h.createModel).toHaveBeenCalledWith("{}", "json", expect.anything());
@@ -84,18 +106,14 @@ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
84
106
  expect(el).toBeInstanceOf(HTMLDivElement);
85
107
  });
86
108
 
87
- it("exposes getActiveEditor() returning the Monaco instance after mount", () => {
109
+ it("exposes getActiveEditor() returning the Monaco instance after mount", async () => {
88
110
  const ref = createRef<CodeWorkspaceHandle>();
89
111
  render(<CodeWorkspace files={FILES} ref={ref} />);
90
- // onMount fires synchronously inside the mocked monaco.editor.create path.
91
- // The mock `create` calls onMountRef immediately after setEditor in the effect
92
- // but since monaco.editor.create is mocked synchronously and CodeEditor calls
93
- // onMountRef.current?.(instance) right after create(), the instance is available.
94
- // We verify getActiveEditor() returns the mock editor (or null if mount async).
112
+ // The engine loads via `import("monaco-editor")`, resolved as a microtask
113
+ // (mocked module, no real network) flush it, then the handle reflects
114
+ // the mounted instance.
115
+ await flush();
95
116
  const active = ref.current!.getActiveEditor();
96
- // The mock editor.create is called synchronously in the useEffect, so after
97
- // the first render + effect flush the handle should reflect it.
98
- // If it's null the store hasn't flushed yet — both are valid jsdom outcomes.
99
117
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
118
  expect(active === null || (active as any) === h.editor).toBe(true);
101
119
  });
@@ -144,3 +162,87 @@ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
144
162
  expect(listener).not.toHaveBeenCalled(); // not called yet, only on selection change
145
163
  });
146
164
  });
165
+
166
+ describe("CodeWorkspace — tab/panel a11y (#154)", () => {
167
+ it("gives every tab an aria-controls that resolves to a real element, for paths with '/', '.', a space and non-ASCII", () => {
168
+ const trickyFiles: EditorFile[] = [
169
+ { path: "src/hello.ts", value: "a" },
170
+ { path: "a b/c.d.json", value: "b" },
171
+ { path: "café/résumé.md", value: "c" },
172
+ ];
173
+ render(<CodeWorkspace files={trickyFiles} />);
174
+ const tabs = screen.getAllByRole("tab");
175
+ expect(tabs).toHaveLength(trickyFiles.length);
176
+ for (const tab of tabs) {
177
+ const controls = tab.getAttribute("aria-controls");
178
+ expect(controls).toBeTruthy();
179
+ expect(document.getElementById(controls!)).not.toBeNull();
180
+ }
181
+ });
182
+ });
183
+
184
+ describe("CodeWorkspace — force-mounted panels stay out of the layout", () => {
185
+ const threeFiles: EditorFile[] = [
186
+ { path: "a.ts", value: "A" },
187
+ { path: "b.ts", value: "B" },
188
+ { path: "c.ts", value: "C" },
189
+ ];
190
+
191
+ it("hides every inactive tabpanel so it takes no layout space and no tab stop", () => {
192
+ render(<CodeWorkspace files={threeFiles} />);
193
+ const panels = screen.getAllByRole("tabpanel", { hidden: true });
194
+ expect(panels).toHaveLength(3);
195
+ const active = panels.filter((p) => p.getAttribute("data-state") === "active");
196
+ const inactive = panels.filter((p) => p.getAttribute("data-state") !== "active");
197
+ expect(active).toHaveLength(1);
198
+ expect(active[0]!.hasAttribute("hidden")).toBe(false);
199
+ expect(active[0]!.getAttribute("tabindex")).toBe("0");
200
+ for (const panel of inactive) {
201
+ expect(panel.hasAttribute("hidden")).toBe(true);
202
+ expect(panel.getAttribute("tabindex")).toBe("-1");
203
+ }
204
+ });
205
+
206
+ it("keeps aria-controls resolvable while the inactive panels are hidden", () => {
207
+ render(<CodeWorkspace files={threeFiles} />);
208
+ for (const tab of screen.getAllByRole("tab")) {
209
+ const controls = tab.getAttribute("aria-controls");
210
+ expect(controls).toBeTruthy();
211
+ expect(document.getElementById(controls!)).not.toBeNull();
212
+ }
213
+ });
214
+ });
215
+
216
+ describe("CodeWorkspace — tab identity survives a file-list reorder (#412 review)", () => {
217
+ it("keeps the active panel's id stable and does not remount Monaco when a file is prepended", async () => {
218
+ const initial: EditorFile[] = [
219
+ { path: "src/a.ts", value: "AAA" },
220
+ { path: "b.json", value: "{}" },
221
+ ];
222
+ const { rerender } = render(<CodeWorkspace files={initial} />);
223
+ await flush();
224
+ const idBefore = screen
225
+ .getAllByRole("tabpanel", { hidden: true })
226
+ .find((p) => p.getAttribute("data-state") === "active")!.id;
227
+ const createdBefore = h.create.mock.calls.length;
228
+
229
+ rerender(<CodeWorkspace files={[{ path: "zz/new.ts", value: "N" }, ...initial]} />);
230
+
231
+ const idAfter = screen
232
+ .getAllByRole("tabpanel", { hidden: true })
233
+ .find((p) => p.getAttribute("data-state") === "active")!.id;
234
+ expect(idAfter).toBe(idBefore);
235
+ expect(h.create.mock.calls.length).toBe(createdBefore);
236
+ });
237
+
238
+ it("gives colliding paths distinct tab values", () => {
239
+ const colliding: EditorFile[] = [
240
+ { path: "a/b", value: "1" },
241
+ { path: "a.b", value: "2" },
242
+ { path: "a-b", value: "3" },
243
+ ];
244
+ render(<CodeWorkspace files={colliding} />);
245
+ const ids = screen.getAllByRole("tabpanel", { hidden: true }).map((p) => p.id);
246
+ expect(new Set(ids).size).toBe(colliding.length);
247
+ });
248
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Tabs, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
3
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import {
6
6
  forwardRef,
@@ -74,6 +74,31 @@ const EXT_LANGUAGE: Record<string, string> = {
74
74
 
75
75
  const basename = (path: string) => path.split("/").pop() || path;
76
76
 
77
+ /**
78
+ * Radix composes each `TabsContent`'s DOM id from the `Tabs`/`TabsTrigger`
79
+ * `value` (see #154): a raw `file.path` leaks `/`, `.`, spaces and non-ASCII
80
+ * characters straight into that id, which is what a screen reader gets back
81
+ * via `aria-controls`. `path` stays the display label and the lookup key in
82
+ * component state (`active`); this is only ever a DOM-id-safe Tabs value.
83
+ *
84
+ * The encoding is INJECTIVE and depends only on the path — never on the
85
+ * file's position in `files`. Every character outside `[A-Za-z0-9-]` becomes
86
+ * `_<hex code point>_`, and `_` itself is never emitted literally, so the
87
+ * mapping is reversible and two distinct paths can never produce the same
88
+ * value ("a/b" → `tab-a_2f_b`, "a.b" → `tab-a_2e_b`). An index-derived value
89
+ * would have been collision-safe too, but it changes when the list is
90
+ * reordered or a file is inserted, which re-keys the active `TabsContent` and
91
+ * throws away Monaco's selection, scroll position and undo history on an
92
+ * otherwise harmless `files` update.
93
+ */
94
+ function tabIdForFile(path: string): string {
95
+ let out = "";
96
+ for (const char of path) {
97
+ out += /[A-Za-z0-9-]/.test(char) ? char : `_${char.codePointAt(0)!.toString(16)}_`;
98
+ }
99
+ return `tab-${out}`;
100
+ }
101
+
77
102
  function inferLanguage(file: EditorFile): string {
78
103
  if (file.language) return file.language;
79
104
  const ext = file.path.split(".").pop()?.toLowerCase() ?? "";
@@ -143,6 +168,10 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
143
168
  const activeFile = files.find((f) => f.path === requested) ?? files[0];
144
169
  const active = activeFile?.path ?? "";
145
170
 
171
+ // DOM-id-safe Tabs value per file (#154); `path` stays the lookup key.
172
+ const tabs = files.map((file) => ({ file, id: tabIdForFile(file.path) }));
173
+ const activeTabId = tabs.find((t) => t.file.path === active)?.id ?? "";
174
+
146
175
  // The root div ref (getElement() preserves old HTMLDivElement access).
147
176
  const rootRef = useRef<HTMLDivElement | null>(null);
148
177
 
@@ -213,16 +242,19 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
213
242
  {...props}
214
243
  >
215
244
  <Tabs
216
- value={active}
217
- onValueChange={selectTab}
245
+ value={activeTabId}
246
+ onValueChange={(id) => {
247
+ const found = tabs.find((t) => t.id === id);
248
+ if (found) selectTab(found.file.path);
249
+ }}
218
250
  className="flex min-h-0 flex-1 flex-col gap-0"
219
251
  >
220
252
  <div className="flex items-center border-b border-border bg-surface">
221
253
  <TabsList className="h-9 flex-1 justify-start gap-0 rounded-none bg-transparent p-0">
222
- {files.map((file) => (
254
+ {tabs.map(({ file, id }) => (
223
255
  <TabsTrigger
224
- key={file.path}
225
- value={file.path}
256
+ key={id}
257
+ value={id}
226
258
  className="h-9 rounded-none border-e border-border px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-none"
227
259
  >
228
260
  {basename(file.path)}
@@ -232,20 +264,41 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
232
264
  {activeFile ? <CopyButton value={activeFile.value} className="me-1.5" /> : null}
233
265
  </div>
234
266
 
235
- <div className="min-h-0 flex-1">
236
- {activeFile ? (
237
- <CodeEditor
238
- key={activeFile.path}
239
- path={activeFile.path}
240
- language={inferLanguage(activeFile)}
241
- value={activeFile.value}
242
- readOnly={readOnly}
243
- onChange={(next) => onFileChange?.(activeFile.path, next)}
244
- onMount={(editor) => setActiveEditor(editor)}
245
- height="100%"
246
- />
247
- ) : null}
248
- </div>
267
+ {tabs.map(({ file, id }) => {
268
+ const isActive = file.path === activeFile?.path;
269
+ return (
270
+ // `forceMount` keeps every tab's content in the DOM so each
271
+ // trigger's `aria-controls` resolves to a real element, not only
272
+ // the active one (#154). Radix derives its own `hidden` from
273
+ // `forceMount || isSelected`, so under `forceMount` it hides
274
+ // NOTHING: without the explicit `hidden` below every inactive
275
+ // panel stays a visible `flex-1` child and the editor height is
276
+ // split across all of them. `tabIndex={-1}` keeps the empty
277
+ // panels out of the tab sequence for the same reason. Only the
278
+ // active file mounts a (single) Monaco instance.
279
+ <TabsContent
280
+ key={id}
281
+ value={id}
282
+ forceMount
283
+ hidden={!isActive}
284
+ tabIndex={isActive ? 0 : -1}
285
+ className="mt-0 min-h-0 flex-1"
286
+ >
287
+ {isActive ? (
288
+ <CodeEditor
289
+ key={file.path}
290
+ path={file.path}
291
+ language={inferLanguage(file)}
292
+ value={file.value}
293
+ readOnly={readOnly}
294
+ onChange={(next) => onFileChange?.(file.path, next)}
295
+ onMount={(editor) => setActiveEditor(editor)}
296
+ height="100%"
297
+ />
298
+ ) : null}
299
+ </TabsContent>
300
+ );
301
+ })}
249
302
  </Tabs>
250
303
  </div>
251
304
  );
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button, useCopyToClipboard } from "@elabs-ai/components-ui";
3
+ import { Button, useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import { CheckIcon, CopyIcon } from "lucide-react";
6
6
  import { useCallback, type ComponentProps } from "react";
@@ -21,11 +21,14 @@ export function CopyButton({ value, label = true, className, ...props }: CopyBut
21
21
  // hook, so this button and `CopyableValue` cannot drift on timing or on what
22
22
  // happens where there is no clipboard.
23
23
  const { copied, copy } = useCopyToClipboard();
24
+ const { t } = useLocale();
24
25
 
25
26
  const onClick = useCallback(() => {
26
27
  void copy(value);
27
28
  }, [copy, value]);
28
29
 
30
+ const text = copied ? t("editor.copyButton.copied") : t("copy");
31
+
29
32
  return (
30
33
  <Button
31
34
  variant="ghost"
@@ -34,7 +37,7 @@ export function CopyButton({ value, label = true, className, ...props }: CopyBut
34
37
  type="button"
35
38
  className={cn("h-7 gap-1.5", className)}
36
39
  onClick={onClick}
37
- aria-label={copied ? "Copied" : "Copy"}
40
+ aria-label={text}
38
41
  >
39
42
  {copied ? (
40
43
  <CheckIcon
@@ -45,7 +48,7 @@ export function CopyButton({ value, label = true, className, ...props }: CopyBut
45
48
  ) : (
46
49
  <CopyIcon className="size-4" aria-hidden="true" />
47
50
  )}
48
- {label ? <span className="text-xs">{copied ? "Copied" : "Copy"}</span> : null}
51
+ {label ? <span className="text-xs">{text}</span> : null}
49
52
  </Button>
50
53
  );
51
54
  }
@@ -5,7 +5,15 @@ const meta = {
5
5
  title: "Editor/DiffEditor",
6
6
  component: DiffEditor,
7
7
  tags: ["autodocs"],
8
- parameters: { layout: "fullscreen" },
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The only diff surface that can be TYPED INTO — Monaco’s side-by-side editor. Reading lines of a patch is `AI/DiffView`, accepting or rejecting per hunk is `AI/ChangeReview`, and reading a patch in a console transcript is `Terminal/TerminalDiffHunk`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
9
17
  } satisfies Meta<typeof DiffEditor>;
10
18
 
11
19
  export default meta;
@@ -1,4 +1,4 @@
1
- import { cleanup, render } from "@testing-library/react";
1
+ import { act, cleanup, render } from "@testing-library/react";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  // Monaco can't mount in jsdom — mock it and assert the wrapper's lifecycle.
@@ -38,6 +38,10 @@ vi.mock("monaco-editor", () => ({
38
38
 
39
39
  import { DiffEditor } from "./diff-editor";
40
40
 
41
+ // See the matching note in `../code-editor/code-editor.test.tsx` — the engine
42
+ // loads via a dynamic `import("monaco-editor")`; flush it before asserting.
43
+ const flush = () => act(async () => {});
44
+
41
45
  beforeEach(() => {
42
46
  h.models.length = 0;
43
47
  vi.clearAllMocks();
@@ -45,9 +49,10 @@ beforeEach(() => {
45
49
  afterEach(cleanup);
46
50
 
47
51
  describe("DiffEditor", () => {
48
- it("creates a diff editor with original + modified models", () => {
52
+ it("creates a diff editor with original + modified models", async () => {
49
53
  const { getByTestId } = render(<DiffEditor original="a" modified="b" language="typescript" />);
50
54
  expect(getByTestId("diff-editor")).toBeInTheDocument();
55
+ await flush();
51
56
  expect(h.createDiffEditor).toHaveBeenCalledTimes(1);
52
57
  expect(h.createModel).toHaveBeenNthCalledWith(1, "a", "typescript");
53
58
  expect(h.createModel).toHaveBeenNthCalledWith(2, "b", "typescript");
@@ -57,8 +62,9 @@ describe("DiffEditor", () => {
57
62
  });
58
63
  });
59
64
 
60
- it("disposes the editor + both models on unmount", () => {
65
+ it("disposes the editor + both models on unmount", async () => {
61
66
  const { unmount } = render(<DiffEditor original="a" modified="b" />);
67
+ await flush();
62
68
  const [original, modified] = h.models;
63
69
  unmount();
64
70
  expect(h.diff.dispose).toHaveBeenCalledTimes(1);
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
 
3
- import * as monaco from "monaco-editor";
3
+ // Type-only see the matching note in `../code-editor/code-editor.tsx`. The
4
+ // engine loads at RUNTIME via `import("monaco-editor")` in the mount effect
5
+ // below (`monacoRef`), so importing this module (e.g. transitively, via the
6
+ // barrel) never evaluates Monaco.
7
+ import type * as monaco from "monaco-editor";
4
8
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
9
  import {
6
10
  forwardRef,
@@ -16,6 +20,9 @@ import { useDataTheme } from "../lib/use-data-theme";
16
20
 
17
21
  export type MonacoDiffEditor = monaco.editor.IStandaloneDiffEditor;
18
22
 
23
+ /** Same pattern as `code-editor.tsx`'s `MonacoNamespace`. */
24
+ type MonacoNamespace = typeof monaco;
25
+
19
26
  export interface DiffEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue"> {
20
27
  /** Left/original document. */
21
28
  original: string;
@@ -32,7 +39,7 @@ export interface DiffEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "d
32
39
  /** Passthrough Monaco diff options (merged over the defaults). */
33
40
  options?: monaco.editor.IStandaloneDiffEditorConstructionOptions;
34
41
  /** Called once the diff editor + monaco namespace are ready. */
35
- onMount?: (editor: MonacoDiffEditor, monacoApi: typeof monaco) => void;
42
+ onMount?: (editor: MonacoDiffEditor, monacoApi: MonacoNamespace) => void;
36
43
  }
37
44
 
38
45
  const BASE_OPTIONS: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
@@ -68,6 +75,10 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
68
75
  const containerRef = useRef<HTMLDivElement>(null);
69
76
  const [editor, setEditor] = useState<MonacoDiffEditor | null>(null);
70
77
  const { theme, revision } = useDataTheme();
78
+ // Populated once the dynamic `import("monaco-editor")` below resolves.
79
+ // `editor` (React state) is only ever set AFTER this ref, so every other
80
+ // effect that reads both may assume: `editor` truthy implies this truthy.
81
+ const monacoRef = useRef<MonacoNamespace | null>(null);
71
82
 
72
83
  const onMountRef = useRef(onMount);
73
84
  onMountRef.current = onMount;
@@ -76,28 +87,39 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
76
87
  editor,
77
88
  ]);
78
89
 
79
- // Mount once.
90
+ // Mount once. Monaco itself loads lazily (see the top-of-file note) — merely
91
+ // importing this module never evaluates the engine.
80
92
  useEffect(() => {
81
93
  const container = containerRef.current;
82
94
  if (!container) return;
83
-
84
- const instance = monaco.editor.createDiffEditor(container, {
85
- ...BASE_OPTIONS,
86
- readOnly,
87
- renderSideBySide,
88
- ...options,
95
+ let cancelled = false;
96
+ let instance: MonacoDiffEditor | null = null;
97
+ let originalModel: monaco.editor.ITextModel | null = null;
98
+ let modifiedModel: monaco.editor.ITextModel | null = null;
99
+
100
+ import("monaco-editor").then((monacoApi) => {
101
+ if (cancelled) return;
102
+ monacoRef.current = monacoApi;
103
+ instance = monacoApi.editor.createDiffEditor(container, {
104
+ ...BASE_OPTIONS,
105
+ readOnly,
106
+ renderSideBySide,
107
+ ...options,
108
+ });
109
+ originalModel = monacoApi.editor.createModel(original, language);
110
+ modifiedModel = monacoApi.editor.createModel(modified, language);
111
+ instance.setModel({ original: originalModel, modified: modifiedModel });
112
+ // Theme is applied by the effect below once `setEditor` runs.
113
+ setEditor(instance);
114
+ onMountRef.current?.(instance, monacoApi);
89
115
  });
90
- const originalModel = monaco.editor.createModel(original, language);
91
- const modifiedModel = monaco.editor.createModel(modified, language);
92
- instance.setModel({ original: originalModel, modified: modifiedModel });
93
- // Theme is applied by the effect below once `setEditor` runs.
94
- setEditor(instance);
95
- onMountRef.current?.(instance, monaco);
96
116
 
97
117
  return () => {
98
- instance.dispose();
99
- originalModel.dispose();
100
- modifiedModel.dispose();
118
+ cancelled = true;
119
+ instance?.dispose();
120
+ originalModel?.dispose();
121
+ modifiedModel?.dispose();
122
+ monacoRef.current = null;
101
123
  setEditor(null);
102
124
  };
103
125
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -114,10 +136,11 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
114
136
  }, [editor, modified]);
115
137
 
116
138
  useEffect(() => {
139
+ const monacoApi = monacoRef.current;
117
140
  const models = editor?.getModel();
118
- if (!models) return;
119
- monaco.editor.setModelLanguage(models.original, language);
120
- monaco.editor.setModelLanguage(models.modified, language);
141
+ if (!models || !monacoApi) return;
142
+ monacoApi.editor.setModelLanguage(models.original, language);
143
+ monacoApi.editor.setModelLanguage(models.modified, language);
121
144
  }, [editor, language]);
122
145
 
123
146
  useEffect(() => {
@@ -125,9 +148,10 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
125
148
  }, [editor, readOnly, renderSideBySide]);
126
149
 
127
150
  useEffect(() => {
128
- if (!editor) return;
151
+ const monacoApi = monacoRef.current;
152
+ if (!editor || !monacoApi) return;
129
153
  try {
130
- applyBrandTheme(monaco, theme);
154
+ applyBrandTheme(monacoApi, theme);
131
155
  } catch (err) {
132
156
  console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
133
157
  }
@@ -6,6 +6,7 @@ import {
6
6
  SelectItem,
7
7
  SelectTrigger,
8
8
  SelectValue,
9
+ useLocale,
9
10
  } from "@elabs-ai/components-ui";
10
11
  import { cn } from "@elabs-ai/components-ui/lib/cn";
11
12
  import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
@@ -45,6 +46,7 @@ export const EditorToolbar = forwardRef<HTMLDivElement, EditorToolbarProps>(func
45
46
  },
46
47
  ref,
47
48
  ) {
49
+ const { t } = useLocale();
48
50
  return (
49
51
  <div
50
52
  ref={ref}
@@ -63,8 +65,12 @@ export const EditorToolbar = forwardRef<HTMLDivElement, EditorToolbarProps>(func
63
65
 
64
66
  {onLanguageChange ? (
65
67
  <Select value={language} onValueChange={onLanguageChange}>
66
- <SelectTrigger size="sm" className="h-7 w-[140px]" aria-label="Language">
67
- <SelectValue placeholder="Language" />
68
+ <SelectTrigger
69
+ size="sm"
70
+ className="h-7 w-[140px]"
71
+ aria-label={t("editor.editorToolbar.language")}
72
+ >
73
+ <SelectValue placeholder={t("editor.editorToolbar.language")} />
68
74
  </SelectTrigger>
69
75
  <SelectContent>
70
76
  {languages.map((l) => (
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export { applyBrandTheme, buildBrandThemeData, brandThemeId } from "./lib/monaco
43
43
  export { useDataTheme, type DataThemeState } from "./lib/use-data-theme";
44
44
  export { EDITOR_LANGUAGES, languageLabel, type EditorLanguage } from "./lib/languages";
45
45
 
46
- // Re-export the monaco namespace so consumers can build custom editors / wire
47
- // commands without adding a direct dependency.
48
- export * as monaco from "monaco-editor";
46
+ // The `monaco-editor` namespace moved to its own subpath see `./monaco.ts`.
47
+ // Re-exporting it here pulled Monaco into every consumer of this barrel, even
48
+ // one only importing `CopyButton`/`EDITOR_LANGUAGES`:
49
+ // import { monaco } from "@elabs-ai/components-editor/monaco";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Ambient declarations for the `monaco-editor` deep ESM paths used by
3
+ * `monaco-theme-bridge.test.ts` to resolve syntax colours through Monaco's
4
+ * REAL token-theme trie (#90) — the only way to prove the bridge's rules
5
+ * actually beat the inherited `vs`/`vs-dark` base themes, since the bug this
6
+ * locks is invisible to a check that only reads `buildBrandThemeData()`'s
7
+ * returned array.
8
+ *
9
+ * `monaco-editor`'s package `exports` map (`"./*": "./*"`) makes these paths
10
+ * resolvable at runtime/bundle time, but ships no `.d.ts` for anything below
11
+ * the top-level `editor.main.d.ts` barrel — hence the shims. Typed only as
12
+ * far as the test needs; internals (`ThemeTrieElementRule`'s private-by-
13
+ * convention `_foreground`/`_background` fields) are read structurally
14
+ * because that's the real shape `tokenization.js` constructs at runtime (see
15
+ * `ThemeTrieElementRule` in that file for the source of truth).
16
+ */
17
+ declare module "monaco-editor/esm/vs/editor/common/languages/supports/tokenization.js" {
18
+ import type * as Monaco from "monaco-editor";
19
+
20
+ /** Mirrors `ThemeTrieElementRule` — the resolved node `_match` returns. */
21
+ interface ResolvedThemeTrieRule {
22
+ readonly _fontStyle: number;
23
+ readonly _foreground: number;
24
+ readonly _background: number;
25
+ readonly metadata: number;
26
+ }
27
+
28
+ /** Mirrors `Color` (`base/common/color.js`) — enough to read RGB channels. */
29
+ interface MonacoColor {
30
+ readonly rgba: { r: number; g: number; b: number; a: number };
31
+ toString(): string;
32
+ }
33
+
34
+ export class TokenTheme {
35
+ static createFromRawTokenTheme(
36
+ source: Monaco.editor.ITokenThemeRule[],
37
+ customTokenColors: string[],
38
+ ): TokenTheme;
39
+ /** Internal (underscore) but exercised directly — see file header. */
40
+ _match(token: string): ResolvedThemeTrieRule;
41
+ getColorMap(): MonacoColor[];
42
+ }
43
+ }
44
+
45
+ declare module "monaco-editor/esm/vs/editor/standalone/common/themes.js" {
46
+ import type * as Monaco from "monaco-editor";
47
+
48
+ interface BuiltinRawTheme {
49
+ readonly base: string;
50
+ readonly inherit: boolean;
51
+ readonly rules: Monaco.editor.ITokenThemeRule[];
52
+ readonly colors: Record<string, string>;
53
+ }
54
+
55
+ export const vs: BuiltinRawTheme;
56
+ export const vs_dark: BuiltinRawTheme;
57
+ export const hc_black: BuiltinRawTheme;
58
+ export const hc_light: BuiltinRawTheme;
59
+ }