@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
@@ -9,6 +9,16 @@
9
9
  * settles), so the double-mount converges to a single editor. The co-located
10
10
  * `markdown-editor.strictmode.test.tsx` is the gate that proves that convergence
11
11
  * for our vendored copy; harden here only if that test ever shows >1 surface.
12
+ *
13
+ * DESTROY gets the same rigor (issue #65). Effect cleanup fires
14
+ * `editor.destroy()` but nothing awaited it, and `@milkdown/ctx` schedules its
15
+ * own internal async cleanup (a timer) inside that promise — so an unmount that
16
+ * doesn't wait can let the timer fire after Vitest has already recycled the
17
+ * file's jsdom environment (`ReferenceError: removeEventListener is not
18
+ * defined`). `pendingDestroys` tracks every in-flight `destroy()`, and
19
+ * `waitForPendingMilkdownTeardown()` lets a caller — a test's `afterEach`, or any
20
+ * consumer that cares — await "every Milkdown teardown has settled" the same way
21
+ * `create()` already converges before this effect resolves.
12
22
  */
13
23
  import { createContext, useContext, useEffect, useRef } from "react";
14
24
 
@@ -16,6 +26,25 @@ import type { EditorInfoCtx } from "./types";
16
26
 
17
27
  export const editorInfoContext = createContext<EditorInfoCtx>({} as EditorInfoCtx);
18
28
 
29
+ /** Module-scoped registry of in-flight Milkdown `destroy()` promises. Not part
30
+ * of the package's public API (not re-exported from `./index`) — reached by
31
+ * relative import from tests/consumers that specifically need to await
32
+ * teardown, mirroring how `editorInfoContext` itself stays internal. */
33
+ const pendingDestroys = new Set<Promise<unknown>>();
34
+
35
+ /** True while at least one Milkdown `destroy()` is still in flight. */
36
+ export function hasPendingMilkdownTeardown(): boolean {
37
+ return pendingDestroys.size > 0;
38
+ }
39
+
40
+ /** Await every Milkdown `destroy()` currently in flight. Resolves immediately
41
+ * when none are pending. Never rejects — `destroy()` already routes its own
42
+ * failure through `console.error` (see the cleanup below), so a caller awaiting
43
+ * teardown only needs "settled", not "succeeded". */
44
+ export function waitForPendingMilkdownTeardown(): Promise<void> {
45
+ return Promise.all(pendingDestroys).then(() => undefined);
46
+ }
47
+
19
48
  export function useGetEditor() {
20
49
  const {
21
50
  dom,
@@ -48,7 +77,14 @@ export function useGetEditor() {
48
77
  .catch(console.error);
49
78
 
50
79
  return () => {
51
- editor.destroy().catch(console.error);
80
+ // `.catch(console.error)` means this promise itself never rejects, so
81
+ // it's safe to await via `Promise.all` in `waitForPendingMilkdownTeardown`
82
+ // without a stray unhandled rejection.
83
+ const destroyPromise = editor.destroy().catch(console.error);
84
+ pendingDestroys.add(destroyPromise);
85
+ void destroyPromise.finally(() => {
86
+ pendingDestroys.delete(destroyPromise);
87
+ });
52
88
  };
53
89
  }, [dom, editorRef, getEditor, setLoading]);
54
90
 
@@ -123,8 +123,9 @@ function makeErrorChip(message: string): HTMLElement {
123
123
  chip.setAttribute("role", "alert");
124
124
  chip.setAttribute("aria-live", "assertive");
125
125
  chip.setAttribute("title", message);
126
+ // #124: the chip's own "Upload failed" label is running text — ink rung.
126
127
  chip.className =
127
- "inline-flex items-center gap-1 rounded px-2 py-0.5 text-caption bg-destructive/10 text-destructive " +
128
+ "inline-flex items-center gap-1 rounded px-2 py-0.5 text-caption bg-destructive/10 text-destructive-text " +
128
129
  "border border-destructive/30 select-none";
129
130
 
130
131
  const label = document.createElement("span");
@@ -14,19 +14,22 @@ import { SlashMenu } from "./slash-menu";
14
14
  * `MarkdownWorkspace` (default `true`).
15
15
  *
16
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
17
+ * branding + a11y can be verified directly across every theme; the in-editor
18
18
  * stories exercise the integration (mount + the `slashMenu={false}` no-op). The
19
19
  * live keyboard/insert UX is best verified in a real browser — ProseMirror
20
20
  * keystroke dispatch can't be fully simulated in jsdom.
21
21
  */
22
22
  const meta = {
23
- title: "Editor/MarkdownEditor/Slash menu",
23
+ title: "Editor/MarkdownEditor/SlashMenu",
24
24
  component: SlashMenu,
25
25
  tags: ["autodocs"],
26
26
  parameters: {
27
27
  docs: {
28
28
  description: {
29
29
  component:
30
+ "The EDITOR slash menu, which inserts ProseMirror nodes; a chat composer uses " +
31
+ "`AI/Composer/PromptInputSlash` and a console composer `Terminal/TerminalSlashMenu` — see " +
32
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
30
33
  "Branded `/` command menu for the WYSIWYG editor. Inserts brand `:::` " +
31
34
  "directives + basic blocks at the caret. Token-driven `listbox`/`option` " +
32
35
  "list; the editor `textbox` owns `aria-activedescendant`.",
@@ -92,7 +95,9 @@ export const Empty: Story = {
92
95
  ),
93
96
  play: async ({ canvasElement }) => {
94
97
  const canvas = within(canvasElement);
95
- await expect(canvas.getByText("No matching blocks")).toBeVisible();
98
+ // A `role="option"` (not a bare div), so it's reachable via an
99
+ // assistive-tech query and stays inside the listbox contract (#157).
100
+ await expect(canvas.getByRole("option", { name: "No matching blocks" })).toBeVisible();
96
101
  },
97
102
  };
98
103
 
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Locking test for #157 (site 1): `SlashMenu`'s empty state used to be a bare
3
+ * `<div>` inside `role="listbox"` — not `option`/`group`, so a user who types
4
+ * a query matching nothing got silence (axe `aria-required-children`).
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { render, screen } from "@testing-library/react";
8
+
9
+ import { SlashMenu } from "./slash-menu";
10
+
11
+ describe("SlashMenu — empty state a11y (#157)", () => {
12
+ it("announces the no-match message as a disabled option, not a bare div", () => {
13
+ render(<SlashMenu commands={[]} onSelect={() => {}} emptyLabel="No matching blocks" />);
14
+
15
+ const listbox = screen.getByRole("listbox", { name: "Insert block" });
16
+ // Reachable via an assistive-tech query, not only present in the DOM.
17
+ const empty = screen.getByRole("option", { name: "No matching blocks" });
18
+ expect(listbox).toContainElement(empty);
19
+ expect(empty).toHaveAttribute("aria-disabled", "true");
20
+
21
+ // Every direct child of the listbox is an `option` or `group` — never a
22
+ // bare, roleless element.
23
+ for (const child of Array.from(listbox.children)) {
24
+ expect(["option", "group"]).toContain(child.getAttribute("role"));
25
+ }
26
+ });
27
+ });
@@ -14,6 +14,7 @@
14
14
  * Semantic tokens only; matches the `Command` popover look (bg-popover, accent
15
15
  * selection, muted group headings).
16
16
  */
17
+ import { useLocale } from "@elabs-ai/components-ui";
17
18
  import { cn } from "@elabs-ai/components-ui/lib/cn";
18
19
  import { forwardRef, type HTMLAttributes } from "react";
19
20
 
@@ -43,12 +44,14 @@ export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(function Sla
43
44
  activeId,
44
45
  onSelect,
45
46
  idPrefix = "brand-slash",
46
- emptyLabel = "No matching blocks",
47
+ emptyLabel: emptyLabelProp,
47
48
  className,
48
49
  ...props
49
50
  },
50
51
  ref,
51
52
  ) {
53
+ const { t } = useLocale();
54
+ const emptyLabel = emptyLabelProp ?? t("editor.slashMenu.noMatchingBlocks");
52
55
  const groups = groupSlashCommands(commands);
53
56
 
54
57
  return (
@@ -57,7 +60,7 @@ export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(function Sla
57
60
  // The editor surface carries `role="textbox"`; this list is its popup. The
58
61
  // plugin sets `aria-controls`/`aria-activedescendant` on the textbox.
59
62
  role="listbox"
60
- aria-label="Insert block"
63
+ aria-label={t("editor.slashMenu.insertBlock")}
61
64
  className={cn(
62
65
  "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
66
  className,
@@ -65,7 +68,19 @@ export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(function Sla
65
68
  {...props}
66
69
  >
67
70
  {commands.length === 0 ? (
68
- <div className="px-2 py-6 text-center text-caption text-muted-foreground">{emptyLabel}</div>
71
+ // A `role="listbox"` may only own `option`/`group` children (WCAG 1.3.1,
72
+ // #157): a bare `<div>` here is an unannounced, dangling message — an
73
+ // AT user who types a query matching nothing gets silence. Shaping it
74
+ // as a disabled, unselectable option keeps the listbox contract AND
75
+ // makes the message reachable by an accessible-name query.
76
+ <div
77
+ role="option"
78
+ aria-disabled="true"
79
+ aria-selected="false"
80
+ className="cursor-default select-none px-2 py-6 text-center text-caption text-muted-foreground"
81
+ >
82
+ {emptyLabel}
83
+ </div>
69
84
  ) : (
70
85
  groups.map(({ group, commands: groupCommands }) => (
71
86
  <div key={group} role="group" aria-label={group} className="overflow-hidden p-1">
@@ -42,6 +42,7 @@ import { Plugin, PluginKey } from "@milkdown/kit/prose/state";
42
42
  import type { EditorView } from "@milkdown/kit/prose/view";
43
43
  import { $prose } from "@milkdown/kit/utils";
44
44
  import { isInTable } from "@milkdown/kit/prose/tables";
45
+ import { useLocale } from "@elabs-ai/components-ui";
45
46
  import { cn } from "@elabs-ai/components-ui/lib/cn";
46
47
  import { usePluginViewContext } from "@prosemirror-adapter/react";
47
48
  import type { usePluginViewFactory } from "@prosemirror-adapter/react";
@@ -109,10 +110,11 @@ function ToolbarButton({
109
110
  className={cn(
110
111
  "inline-flex h-7 items-center gap-1 rounded px-2 text-caption font-medium",
111
112
  "border border-border-strong",
112
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
113
+ "focus-ring",
113
114
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
115
+ // #124: the button's LABEL text, not a mark — ink rung.
114
116
  variant === "destructive"
115
- ? "bg-background text-destructive hover:bg-destructive/10"
117
+ ? "bg-background text-destructive-text hover:bg-destructive/10"
116
118
  : "bg-background text-foreground hover:bg-surface-muted",
117
119
  )}
118
120
  >
@@ -136,39 +138,49 @@ function ToolbarDivider() {
136
138
  */
137
139
  function TableControlsView() {
138
140
  const { view } = usePluginViewContext();
141
+ const { t } = useLocale();
139
142
  const inTable = isInTable(view.state);
140
143
  const run = (key: Parameters<typeof dispatchCommand>[1]) => () => dispatchCommand(view, key);
141
144
 
142
145
  if (!inTable) return null;
143
146
 
147
+ const addRowAbove = t("editor.tableView.addRowAbove");
148
+ const addRowBelow = t("editor.tableView.addRowBelow");
149
+ const deleteRow = t("editor.tableView.deleteRow");
150
+ const addColLeft = t("editor.tableView.addColumnLeft");
151
+ const addColRight = t("editor.tableView.addColumnRight");
152
+ const deleteCol = t("editor.tableView.deleteColumn");
153
+
144
154
  return (
145
155
  <div
146
156
  role="toolbar"
147
- aria-label="Table controls"
157
+ aria-label={t("editor.tableView.tableControls")}
148
158
  className={cn(
149
159
  "flex flex-wrap items-center gap-1 px-2 py-1.5",
150
160
  "border-t border-border-strong bg-surface-muted",
151
161
  )}
152
162
  >
153
163
  {/* Row group */}
154
- <span className="mr-1 select-none text-caption text-muted-foreground">Row</span>
164
+ <span className="mr-1 select-none text-caption text-muted-foreground">
165
+ {t("editor.tableView.row")}
166
+ </span>
155
167
  <ToolbarButton
156
- ariaLabel="Add row above"
157
- title="Add row above"
168
+ ariaLabel={addRowAbove}
169
+ title={addRowAbove}
158
170
  onClick={run(addRowBeforeCommand.key)}
159
171
  >
160
172
  ↑+
161
173
  </ToolbarButton>
162
174
  <ToolbarButton
163
- ariaLabel="Add row below"
164
- title="Add row below"
175
+ ariaLabel={addRowBelow}
176
+ title={addRowBelow}
165
177
  onClick={run(addRowAfterCommand.key)}
166
178
  >
167
179
  ↓+
168
180
  </ToolbarButton>
169
181
  <ToolbarButton
170
- ariaLabel="Delete row"
171
- title="Delete row"
182
+ ariaLabel={deleteRow}
183
+ title={deleteRow}
172
184
  variant="destructive"
173
185
  onClick={run(deleteSelectedCellsCommand.key)}
174
186
  >
@@ -178,24 +190,26 @@ function TableControlsView() {
178
190
  <ToolbarDivider />
179
191
 
180
192
  {/* Column group */}
181
- <span className="mr-1 select-none text-caption text-muted-foreground">Col</span>
193
+ <span className="mr-1 select-none text-caption text-muted-foreground">
194
+ {t("editor.tableView.col")}
195
+ </span>
182
196
  <ToolbarButton
183
- ariaLabel="Add column left"
184
- title="Add column left"
197
+ ariaLabel={addColLeft}
198
+ title={addColLeft}
185
199
  onClick={run(addColBeforeCommand.key)}
186
200
  >
187
201
  ←+
188
202
  </ToolbarButton>
189
203
  <ToolbarButton
190
- ariaLabel="Add column right"
191
- title="Add column right"
204
+ ariaLabel={addColRight}
205
+ title={addColRight}
192
206
  onClick={run(addColAfterCommand.key)}
193
207
  >
194
208
  →+
195
209
  </ToolbarButton>
196
210
  <ToolbarButton
197
- ariaLabel="Delete column"
198
- title="Delete column"
211
+ ariaLabel={deleteCol}
212
+ title={deleteCol}
199
213
  variant="destructive"
200
214
  onClick={run(deleteSelectedCellsCommand.key)}
201
215
  >
@@ -1,7 +1,7 @@
1
1
  import { Button } from "@elabs-ai/components-ui";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { useState } from "react";
4
- import { expect, userEvent, within } from "storybook/test";
4
+ import { expect, userEvent, waitFor, within } from "storybook/test";
5
5
 
6
6
  import { IterationBuilderDialog } from "./iteration-builder-dialog";
7
7
  import type { IterationBuilderValue } from "./iteration-builder";
@@ -41,6 +41,32 @@ const room = (child: HTMLElement, box: HTMLElement) => {
41
41
  };
42
42
  };
43
43
 
44
+ /**
45
+ * Presence is not visibility. `findByText` resolves the instant the node lands in
46
+ * the DOM — which is INSIDE the dialog's enter animation (`data-[state=open]:animate-in
47
+ * fade-in-0 zoom-in-95` on `DialogContent`), so the query can hand back an element
48
+ * still holding that animation's start value, `opacity: 0`. A one-shot
49
+ * `toBeVisible()` on the node the query just returned then fails.
50
+ *
51
+ * Reduced motion does NOT remove that window: the token backstop in `themes.css`
52
+ * (`@media (prefers-reduced-motion: reduce)`) clamps every animation to `0.01ms`,
53
+ * it does not cancel it — and by taking the enter animation's real duration out of
54
+ * the way it makes the play function reach the assertion sooner. That is why these
55
+ * stories only started failing in CI once the browser context asked for reduced
56
+ * motion (`apps/docs/vitest.config.ts`); the two stories below that already waited
57
+ * for the settled state kept passing in the same run.
58
+ *
59
+ * (The failure PRINTS as an empty element — `<th scope="col" />`. That is jest-dom's
60
+ * formatting, not the DOM: `toBeVisible` reports `element.cloneNode(false)`, a
61
+ * shallow clone, so children are never shown. The element was populated.)
62
+ *
63
+ * So assert the SETTLED end state: re-query inside `waitFor` and re-check visibility
64
+ * until it holds. Not a timeout bump — a stale node can never be asserted, and the
65
+ * text still has to become visible for the story to pass.
66
+ */
67
+ const expectVisibleText = (scope: HTMLElement, text: string) =>
68
+ waitFor(() => expect(within(scope).getByText(text)).toBeVisible(), { timeout: 8000 });
69
+
44
70
  const meta = {
45
71
  title: "Editor/Iteration/BuilderDialog",
46
72
  component: IterationBuilderDialog,
@@ -105,14 +131,12 @@ export const PivotBuilder: Story = {
105
131
  // Each value string renders TWICE — once as a TagInput chip in the left
106
132
  // column, once in the live preview — so scope queries to the preview region
107
133
  // (the confirming pattern from PivotWithCalc / WithNestedCard below).
108
- const preview = within(
109
- await d.findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
110
- );
134
+ const preview = await d.findByRole("region", { name: /live preview/i }, { timeout: 8000 });
111
135
  // The live preview renders a populated matrix from the embedded value lists:
112
136
  // axis headers + an interpolated cell.
113
- await expect(await preview.findByText("North", {}, { timeout: 8000 })).toBeVisible();
114
- await expect(preview.getByText("Q1")).toBeVisible();
115
- await expect(preview.getByText("Q1 · North")).toBeVisible();
137
+ await expectVisibleText(preview, "North");
138
+ await expectVisibleText(preview, "Q1");
139
+ await expectVisibleText(preview, "Q1 · North");
116
140
  },
117
141
  };
118
142
 
@@ -149,13 +173,24 @@ export const IterateBuilder: Story = {
149
173
  const canvas = within(canvasElement);
150
174
  await userEvent.click(canvas.getByRole("button", { name: /Edit iteration/i }));
151
175
  const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
176
+ // The per-cell template field is a Monaco editor (`MarkdownWorkspace`
177
+ // defaultMode="source"). axe scans whatever is in the DOM the instant it
178
+ // runs, so a scan that races Monaco's first paint can measure a token span
179
+ // before its (AA-clamped) syntax color has actually been applied — wait for
180
+ // a real rendered token span first (#88).
181
+ await waitFor(
182
+ () => expect(dialog.querySelector('.view-line span[class*="mtk"]')).toBeTruthy(),
183
+ { timeout: 8000 },
184
+ );
152
185
  // "Alice" renders TWICE — as a TagInput chip AND in the live preview — so
153
186
  // scope to the preview region (mirrors the PivotBuilder fix above).
154
- const preview = within(
155
- await within(dialog).findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
187
+ const preview = await within(dialog).findByRole(
188
+ "region",
189
+ { name: /live preview/i },
190
+ { timeout: 8000 },
156
191
  );
157
192
  // The live preview renders one cell per embedded value.
158
- await expect(await preview.findByText("Alice", {}, { timeout: 8000 })).toBeVisible();
193
+ await expectVisibleText(preview, "Alice");
159
194
 
160
195
  // #425 acceptance criterion: this dialog's `Bind name` field sits directly
161
196
  // against the (now real) `DialogBody` scrollport as its first child — the
@@ -244,12 +279,13 @@ export const WithNestedCard: Story = {
244
279
  const canvas = within(canvasElement);
245
280
  await userEvent.click(canvas.getByRole("button", { name: /Edit iteration with card/i }));
246
281
  const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
247
- const d = within(dialog);
248
- // The nested card rendered as a real component (title + body interpolated)...
249
- await expect(await d.findByText("Lead engineer for Ada", {}, { timeout: 8000 })).toBeVisible();
282
+ // The nested card rendered as a real component (title + body interpolated)
283
+ // waited out through `expectVisibleText` (see its note: presence visibility
284
+ // while the dialog's enter animation is on its first frame)...
285
+ await expectVisibleText(dialog, "Lead engineer for Ada");
250
286
  // ...and the content AFTER it survived (dropped before the fence-collision fix).
251
- await expect(d.getByText("Notes for Ada")).toBeVisible();
252
- await expect(d.getByText("Notes for Grace")).toBeVisible();
287
+ await expectVisibleText(dialog, "Notes for Ada");
288
+ await expectVisibleText(dialog, "Notes for Grace");
253
289
  },
254
290
  };
255
291
 
@@ -284,10 +320,12 @@ export const BentoBuilder: Story = {
284
320
  const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
285
321
  // "Alpha"/"Epsilon" render TWICE — as TagInput chips AND in the live
286
322
  // preview's BentoGrid tiles — so scope to the preview region.
287
- const preview = within(
288
- await within(dialog).findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
323
+ const preview = await within(dialog).findByRole(
324
+ "region",
325
+ { name: /live preview/i },
326
+ { timeout: 8000 },
289
327
  );
290
- await expect(await preview.findByText("Alpha", {}, { timeout: 8000 })).toBeVisible();
291
- await expect(preview.getByText("Epsilon")).toBeVisible();
328
+ await expectVisibleText(preview, "Alpha");
329
+ await expectVisibleText(preview, "Epsilon");
292
330
  },
293
331
  };
@@ -28,6 +28,7 @@ import {
28
28
  TagInput,
29
29
  ToggleGroup,
30
30
  ToggleGroupItem,
31
+ useLocale,
31
32
  } from "@elabs-ai/components-ui";
32
33
  import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
33
34
 
@@ -79,6 +80,7 @@ export function IterationBuilderDialog({
79
80
  evaluate,
80
81
  interpolate,
81
82
  }: IterationBuilderDialogProps) {
83
+ const { t } = useLocale();
82
84
  const kind = value?.kind ?? kindProp;
83
85
  const isPivot = kind === "pivot";
84
86
  const ids = useId();
@@ -130,19 +132,26 @@ export function IterationBuilderDialog({
130
132
  onOpenChange(false);
131
133
  };
132
134
 
133
- const noun = isPivot ? "pivot" : "iteration";
135
+ const noun = isPivot
136
+ ? t("editor.iterationBuilder.pivotNoun")
137
+ : t("editor.iterationBuilder.iterationNoun");
134
138
 
135
139
  return (
136
140
  <Dialog open={open} onOpenChange={onOpenChange}>
137
141
  <DialogContent className="flex max-h-[88vh] w-[min(56rem,94vw)] max-w-none flex-col">
138
142
  <DialogHeader>
139
143
  <DialogTitle>
140
- {value ? "Edit" : "Insert"} {noun}
144
+ {t(
145
+ value ? "editor.iterationBuilder.editTitle" : "editor.iterationBuilder.insertTitle",
146
+ {
147
+ noun,
148
+ },
149
+ )}
141
150
  </DialogTitle>
142
151
  <DialogDescription>
143
152
  {isPivot
144
- ? "Pick the row and column values, then write the per-cell template. The matrix below fills in live."
145
- : "Add the list values, then write the per-row template. The result below fills in live."}
153
+ ? t("editor.iterationBuilder.pivotDescription")
154
+ : t("editor.iterationBuilder.iterationDescription")}
146
155
  </DialogDescription>
147
156
  </DialogHeader>
148
157
 
@@ -151,47 +160,53 @@ export function IterationBuilderDialog({
151
160
  <div className="flex min-w-0 flex-col gap-4">
152
161
  {!isPivot ? (
153
162
  <div className="flex flex-col gap-1.5">
154
- <Label htmlFor={`${ids}-as`}>Bind name</Label>
163
+ <Label htmlFor={`${ids}-as`}>{t("editor.iterationBuilder.bindName")}</Label>
155
164
  <Input
156
165
  id={`${ids}-as`}
157
166
  value={asName}
158
167
  spellCheck={false}
159
168
  autoComplete="off"
160
- placeholder="item"
169
+ placeholder={t("editor.iterationBuilder.bindNamePlaceholder")}
161
170
  onChange={(e) => setAsName(e.target.value)}
162
171
  />
163
172
  <p className="text-meta text-muted-foreground">
164
- Use <code>{`{{${asName.trim() || "item"}.name}}`}</code> in the template.
173
+ {t("editor.iterationBuilder.bindNameHintPrefix")}
174
+ <code>{`{{${asName.trim() || "item"}.name}}`}</code>
175
+ {t("editor.iterationBuilder.bindNameHintSuffix")}
165
176
  </p>
166
177
  </div>
167
178
  ) : null}
168
179
 
169
180
  <div className="flex flex-col gap-1.5">
170
- <Label htmlFor={`${ids}-values`}>{isPivot ? "Row values" : "Values"}</Label>
181
+ <Label htmlFor={`${ids}-values`}>
182
+ {isPivot
183
+ ? t("editor.iterationBuilder.rowValues")
184
+ : t("editor.iterationBuilder.values")}
185
+ </Label>
171
186
  <TagInput
172
187
  id={`${ids}-values`}
173
188
  value={values}
174
189
  onValueChange={setValues}
175
190
  delimiter={[",", "\n"]}
176
- placeholder="Type a value, press Enter…"
191
+ placeholder={t("editor.iterationBuilder.valuePlaceholder")}
177
192
  />
178
193
  </div>
179
194
 
180
195
  {isPivot ? (
181
196
  <div className="flex flex-col gap-1.5">
182
- <Label htmlFor={`${ids}-cols`}>Column values</Label>
197
+ <Label htmlFor={`${ids}-cols`}>{t("editor.iterationBuilder.columnValues")}</Label>
183
198
  <TagInput
184
199
  id={`${ids}-cols`}
185
200
  value={cols}
186
201
  onValueChange={setCols}
187
202
  delimiter={[",", "\n"]}
188
- placeholder="Type a value, press Enter…"
203
+ placeholder={t("editor.iterationBuilder.valuePlaceholder")}
189
204
  />
190
205
  </div>
191
206
  ) : null}
192
207
 
193
208
  <div className="flex flex-col gap-1.5">
194
- <Label id={`${ids}-layout`}>Layout</Label>
209
+ <Label id={`${ids}-layout`}>{t("editor.iterationBuilder.layout")}</Label>
195
210
  <ToggleGroup
196
211
  type="single"
197
212
  variant="segmented"
@@ -216,23 +231,27 @@ export function IterationBuilderDialog({
216
231
  {/* Right column — the per-cell TEMPLATE + the live populated preview. */}
217
232
  <div className="flex min-h-0 min-w-0 flex-col gap-4">
218
233
  <div className="flex min-h-0 flex-col gap-1.5">
219
- <Label>Per-{isPivot ? "cell" : "row"} template</Label>
234
+ <Label>
235
+ {isPivot
236
+ ? t("editor.iterationBuilder.perCellTemplate")
237
+ : t("editor.iterationBuilder.perRowTemplate")}
238
+ </Label>
220
239
  <div className="h-44 min-h-0 overflow-hidden rounded-md border border-border">
221
240
  <MarkdownWorkspace
222
241
  value={template}
223
242
  onChange={setTemplate}
224
243
  defaultMode="source"
225
244
  className="h-full"
226
- aria-label="Per-cell template"
245
+ aria-label={t("editor.iterationBuilder.perCellTemplate")}
227
246
  />
228
247
  </div>
229
248
  </div>
230
249
 
231
250
  <div className="flex min-h-0 flex-col gap-1.5">
232
- <Label>Live preview</Label>
251
+ <Label>{t("editor.iterationBuilder.livePreview")}</Label>
233
252
  <div
234
253
  role="region"
235
- aria-label="Live preview"
254
+ aria-label={t("editor.iterationBuilder.livePreview")}
236
255
  className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-card p-3"
237
256
  >
238
257
  <MarkdownPreview
@@ -249,9 +268,11 @@ export function IterationBuilderDialog({
249
268
 
250
269
  <DialogFooter>
251
270
  <Button variant="ghost" onClick={() => onOpenChange(false)}>
252
- Cancel
271
+ {t("editor.iterationBuilder.cancel")}
272
+ </Button>
273
+ <Button onClick={save}>
274
+ {value ? t("editor.iterationBuilder.save") : t("editor.iterationBuilder.insert")}
253
275
  </Button>
254
- <Button onClick={save}>{value ? "Save" : "Insert"}</Button>
255
276
  </DialogFooter>
256
277
  </DialogContent>
257
278
  </Dialog>
@@ -18,6 +18,7 @@ import {
18
18
  DialogFooter,
19
19
  DialogHeader,
20
20
  DialogTitle,
21
+ useLocale,
21
22
  } from "@elabs-ai/components-ui";
22
23
  import { useEffect, useState, type ReactNode } from "react";
23
24
 
@@ -47,6 +48,7 @@ export function IterationTemplateDialog({
47
48
  kind = "iterate",
48
49
  mode = "split",
49
50
  }: IterationTemplateDialogProps) {
51
+ const { t } = useLocale();
50
52
  const [draft, setDraft] = useState(template);
51
53
 
52
54
  // Re-seed the draft whenever the dialog (re)opens against a new template.
@@ -63,12 +65,28 @@ export function IterationTemplateDialog({
63
65
 
64
66
  return (
65
67
  <Dialog open={open} onOpenChange={onOpenChange}>
66
- <DialogContent className="flex max-h-[85vh] w-[min(48rem,92vw)] max-w-none flex-col">
68
+ <DialogContent
69
+ className="flex max-h-[85vh] w-[min(48rem,92vw)] max-w-none flex-col"
70
+ // Radix autofocuses the first tabbable element — the toolbar's Bold button —
71
+ // and its Tooltip opens on focus, so the dialog opened with a stray tooltip.
72
+ // Focus the dialog itself instead; Tab still reaches the toolbar first.
73
+ onOpenAutoFocus={(event) => {
74
+ event.preventDefault();
75
+ (event.currentTarget as HTMLElement | null)?.focus();
76
+ }}
77
+ >
67
78
  <DialogHeader>
68
- <DialogTitle>Edit {kind === "pivot" ? "pivot" : "iteration"} template</DialogTitle>
79
+ <DialogTitle>
80
+ {kind === "pivot"
81
+ ? t("editor.templateDialog.editPivotTitle")
82
+ : t("editor.templateDialog.editIterationTitle")}
83
+ </DialogTitle>
69
84
  <DialogDescription>
70
- The per-{unit} template. Use <code>{"{{token}}"}</code> placeholders (e.g.{" "}
71
- <code>{"{{item.name}}"}</code>) — each is filled per {unit} when the block renders.
85
+ {t("editor.templateDialog.descriptionPrefix", { unit })}
86
+ <code>{"{{token}}"}</code>
87
+ {t("editor.templateDialog.descriptionMiddle")}
88
+ <code>{"{{item.name}}"}</code>
89
+ {t("editor.templateDialog.descriptionSuffix", { unit })}
72
90
  </DialogDescription>
73
91
  </DialogHeader>
74
92
  <div className="min-h-0 flex-1">
@@ -77,14 +95,14 @@ export function IterationTemplateDialog({
77
95
  onChange={setDraft}
78
96
  defaultMode={mode}
79
97
  className="h-full"
80
- aria-label="Iteration template editor"
98
+ aria-label={t("editor.templateDialog.editorLabel")}
81
99
  />
82
100
  </div>
83
101
  <DialogFooter>
84
102
  <Button variant="ghost" onClick={() => onOpenChange(false)}>
85
- Cancel
103
+ {t("editor.templateDialog.cancel")}
86
104
  </Button>
87
- <Button onClick={save}>Save template</Button>
105
+ <Button onClick={save}>{t("editor.templateDialog.saveTemplate")}</Button>
88
106
  </DialogFooter>
89
107
  </DialogContent>
90
108
  </Dialog>