@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,293 @@
1
+ import { Button } from "@elabs-ai/components-ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
4
+ import { expect, userEvent, within } from "storybook/test";
5
+
6
+ import { IterationBuilderDialog } from "./iteration-builder-dialog";
7
+ import type { IterationBuilderValue } from "./iteration-builder";
8
+ import type { EvaluateCalc } from "../calc-block";
9
+
10
+ // A minimal stand-in calc engine for the story (the app brings its own): every
11
+ // non-empty line resolves to a fixed value, enough to show a ```calc cell
12
+ // RESOLVING into a CalcBlock inside the pivot rather than showing raw text.
13
+ const evaluateCalc: EvaluateCalc = (source) => ({
14
+ results: source
15
+ .split("\n")
16
+ .filter((line) => line.trim().length > 0)
17
+ .map((_, i) => ({
18
+ line: i + 1,
19
+ tokens: [],
20
+ value: { kind: "number" as const, raw: 42, display: "42" },
21
+ })),
22
+ });
23
+
24
+ /**
25
+ * Room between a focused child's border box and its scroll-container ancestor's
26
+ * SCROLLPORT — see `packages/ui/src/components/dialog/dialog-focus-ring-clearance.stories.tsx`
27
+ * for the full derivation. Reused here (not imported — `@elabs-ai/components-editor`
28
+ * doesn't depend on `@elabs-ai/components-ui`'s story files) to confirm #425's fix
29
+ * reaches this dialog now that its scroll region is a real `DialogBody`.
30
+ */
31
+ const room = (child: HTMLElement, box: HTMLElement) => {
32
+ const c = child.getBoundingClientRect();
33
+ const b = box.getBoundingClientRect();
34
+ const l = b.left + box.clientLeft;
35
+ const t = b.top + box.clientTop;
36
+ return {
37
+ left: c.left - l,
38
+ right: l + box.clientWidth - c.right,
39
+ top: c.top - t,
40
+ bottom: t + box.clientHeight - c.bottom,
41
+ };
42
+ };
43
+
44
+ const meta = {
45
+ title: "Editor/Iteration/BuilderDialog",
46
+ component: IterationBuilderDialog,
47
+ tags: ["autodocs"],
48
+ parameters: {
49
+ layout: "centered",
50
+ docs: {
51
+ description: {
52
+ component:
53
+ "The #223 GUIDED builder (A5) — collects the DATA (value list(s) + bind name + layout) " +
54
+ "as well as the per-cell template, shows a LIVE populated preview, and writes a " +
55
+ "fully-bound `:::iterate` / `:::pivot` directive whose value lists live in its " +
56
+ "attributes (rendered by the built-in `evaluateEmbedded`). Wire the node-view `⋯` " +
57
+ "re-edit to it with `IterationBuilderProvider` for a lossless round-trip.",
58
+ },
59
+ },
60
+ },
61
+ } satisfies Meta<typeof IterationBuilderDialog>;
62
+
63
+ export default meta;
64
+ type Story = StoryObj<typeof meta>;
65
+
66
+ const PIVOT_SEED: IterationBuilderValue = {
67
+ kind: "pivot",
68
+ as: "item",
69
+ layout: "matrix",
70
+ values: ["Q1", "Q2", "Q3"],
71
+ cols: ["North", "South"],
72
+ template: "{{row}} · {{col}}",
73
+ };
74
+
75
+ /**
76
+ * Pivot: two value lists → a populated matrix in the live preview. Opening this
77
+ * (the `⋯` re-edit scenario, seeded via `value`) shows the matrix filling in from
78
+ * the embedded row/column lists with no consumer data engine.
79
+ */
80
+ export const PivotBuilder: Story = {
81
+ args: { open: false, onOpenChange: () => {}, onSave: () => {} },
82
+ render: function PivotBuilderStory() {
83
+ const [open, setOpen] = useState(false);
84
+ const [saved, setSaved] = useState<string | null>(null);
85
+ return (
86
+ <div className="flex flex-col items-center gap-3">
87
+ <Button onClick={() => setOpen(true)}>Edit pivot…</Button>
88
+ {saved ? (
89
+ <pre className="max-w-md rounded-md bg-surface-muted p-3 text-caption">{saved}</pre>
90
+ ) : null}
91
+ <IterationBuilderDialog
92
+ open={open}
93
+ onOpenChange={setOpen}
94
+ value={PIVOT_SEED}
95
+ onSave={setSaved}
96
+ />
97
+ </div>
98
+ );
99
+ },
100
+ play: async ({ canvasElement }) => {
101
+ const canvas = within(canvasElement);
102
+ await userEvent.click(canvas.getByRole("button", { name: /Edit pivot/i }));
103
+ const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
104
+ const d = within(dialog);
105
+ // Each value string renders TWICE — once as a TagInput chip in the left
106
+ // column, once in the live preview — so scope queries to the preview region
107
+ // (the confirming pattern from PivotWithCalc / WithNestedCard below).
108
+ const preview = within(
109
+ await d.findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
110
+ );
111
+ // The live preview renders a populated matrix from the embedded value lists:
112
+ // 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();
116
+ },
117
+ };
118
+
119
+ /**
120
+ * Iterate: one value list → one rendered cell per value (stacked).
121
+ */
122
+ export const IterateBuilder: Story = {
123
+ args: { open: false, onOpenChange: () => {}, onSave: () => {} },
124
+ render: function IterateBuilderStory() {
125
+ const [open, setOpen] = useState(false);
126
+ const [saved, setSaved] = useState<string | null>(null);
127
+ return (
128
+ <div className="flex flex-col items-center gap-3">
129
+ <Button onClick={() => setOpen(true)}>Edit iteration…</Button>
130
+ {saved ? (
131
+ <pre className="max-w-md rounded-md bg-surface-muted p-3 text-caption">{saved}</pre>
132
+ ) : null}
133
+ <IterationBuilderDialog
134
+ open={open}
135
+ onOpenChange={setOpen}
136
+ value={{
137
+ kind: "iterate",
138
+ as: "item",
139
+ layout: "stacked",
140
+ values: ["Alice", "Bob", "Charlie"],
141
+ template: "{{item.name}}",
142
+ }}
143
+ onSave={setSaved}
144
+ />
145
+ </div>
146
+ );
147
+ },
148
+ play: async ({ canvasElement }) => {
149
+ const canvas = within(canvasElement);
150
+ await userEvent.click(canvas.getByRole("button", { name: /Edit iteration/i }));
151
+ const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
152
+ // "Alice" renders TWICE — as a TagInput chip AND in the live preview — so
153
+ // 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 }),
156
+ );
157
+ // The live preview renders one cell per embedded value.
158
+ await expect(await preview.findByText("Alice", {}, { timeout: 8000 })).toBeVisible();
159
+
160
+ // #425 acceptance criterion: this dialog's `Bind name` field sits directly
161
+ // against the (now real) `DialogBody` scrollport as its first child — the
162
+ // same worst case `FocusRingClearance` locks on `DialogBody` itself. Assert
163
+ // the fix reaches this consumer rather than assuming it from the swap alone.
164
+ const body = dialog.querySelector<HTMLElement>('[data-slot="dialog-body"]')!;
165
+ const bindNameInput = within(dialog).getByLabelText("Bind name");
166
+ bindNameInput.focus();
167
+ const r = room(bindNameInput, body);
168
+ expect(r.left).toBeGreaterThanOrEqual(3.5);
169
+ expect(r.right).toBeGreaterThanOrEqual(3.5);
170
+ expect(r.top).toBeGreaterThanOrEqual(3.5);
171
+ },
172
+ };
173
+
174
+ /**
175
+ * Pivot with a ```calc cell: objects inside a cell RESOLVE in the live preview
176
+ * (not raw text). With a calc `evaluate` wired, each cell's fence renders a real
177
+ * CalcBlock — the same path the saved block renders through, in matrix and
178
+ * stacked alike.
179
+ */
180
+ export const PivotWithCalc: Story = {
181
+ args: { open: false, onOpenChange: () => {}, onSave: () => {} },
182
+ render: function PivotWithCalcStory() {
183
+ const [open, setOpen] = useState(false);
184
+ return (
185
+ <div className="flex flex-col items-center gap-3">
186
+ <Button onClick={() => setOpen(true)}>Edit pivot with calc…</Button>
187
+ <IterationBuilderDialog
188
+ open={open}
189
+ onOpenChange={setOpen}
190
+ evaluate={evaluateCalc}
191
+ value={{
192
+ kind: "pivot",
193
+ as: "item",
194
+ layout: "matrix",
195
+ values: ["1", "2"],
196
+ cols: ["1", "2"],
197
+ template: "```calc\nitems = {{row}} + {{col}}\ntotal = items\n```",
198
+ }}
199
+ onSave={() => {}}
200
+ />
201
+ </div>
202
+ );
203
+ },
204
+ play: async ({ canvasElement }) => {
205
+ const canvas = within(canvasElement);
206
+ await userEvent.click(canvas.getByRole("button", { name: /Edit pivot with calc/i }));
207
+ const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
208
+ // The calc fence resolved into real CalcBlock(s) inside the matrix cells.
209
+ await within(dialog).findAllByTestId("calc-block", {}, { timeout: 8000 });
210
+ },
211
+ };
212
+
213
+ /**
214
+ * A nested `:::card` (with content AFTER it) inside the iteration template. The live
215
+ * preview renders a REAL Card per item — not truncated plain text — and keeps the
216
+ * trailing line, because the builder serializes the directive with a fence longer
217
+ * than the nested one (`outerDirectiveFence`). This is the #223 "other components
218
+ * inside iterations don't render" fix, shown in the dialog the author actually uses.
219
+ */
220
+ export const WithNestedCard: Story = {
221
+ args: { open: false, onOpenChange: () => {}, onSave: () => {} },
222
+ render: function WithNestedCardStory() {
223
+ const [open, setOpen] = useState(false);
224
+ return (
225
+ <div className="flex flex-col items-center gap-3">
226
+ <Button onClick={() => setOpen(true)}>Edit iteration with card…</Button>
227
+ <IterationBuilderDialog
228
+ open={open}
229
+ onOpenChange={setOpen}
230
+ value={{
231
+ kind: "iterate",
232
+ as: "item",
233
+ layout: "stacked",
234
+ values: ["Ada", "Grace"],
235
+ template:
236
+ ':::card{title="{{item.name}}"}\nLead engineer for {{item.name}}\n:::\n\nNotes for {{item.name}}',
237
+ }}
238
+ onSave={() => {}}
239
+ />
240
+ </div>
241
+ );
242
+ },
243
+ play: async ({ canvasElement }) => {
244
+ const canvas = within(canvasElement);
245
+ await userEvent.click(canvas.getByRole("button", { name: /Edit iteration with card/i }));
246
+ 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();
250
+ // ...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();
253
+ },
254
+ };
255
+
256
+ /**
257
+ * Bento layout: the values render as varied-size BentoGrid tiles in the live preview.
258
+ */
259
+ export const BentoBuilder: Story = {
260
+ args: { open: false, onOpenChange: () => {}, onSave: () => {} },
261
+ render: function BentoBuilderStory() {
262
+ const [open, setOpen] = useState(false);
263
+ return (
264
+ <div className="flex flex-col items-center gap-3">
265
+ <Button onClick={() => setOpen(true)}>Edit bento…</Button>
266
+ <IterationBuilderDialog
267
+ open={open}
268
+ onOpenChange={setOpen}
269
+ value={{
270
+ kind: "iterate",
271
+ as: "item",
272
+ layout: "bento",
273
+ values: ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"],
274
+ template: "**{{item.name}}**",
275
+ }}
276
+ onSave={() => {}}
277
+ />
278
+ </div>
279
+ );
280
+ },
281
+ play: async ({ canvasElement }) => {
282
+ const canvas = within(canvasElement);
283
+ await userEvent.click(canvas.getByRole("button", { name: /Edit bento/i }));
284
+ const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
285
+ // "Alpha"/"Epsilon" render TWICE — as TagInput chips AND in the live
286
+ // 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 }),
289
+ );
290
+ await expect(await preview.findByText("Alpha", {}, { timeout: 8000 })).toBeVisible();
291
+ await expect(preview.getByText("Epsilon")).toBeVisible();
292
+ },
293
+ };
@@ -0,0 +1,312 @@
1
+ "use client";
2
+
3
+ /**
4
+ * IterationBuilderDialog (A5) — GUIDED `:::iterate` / `:::pivot` authoring.
5
+ *
6
+ * Where `IterationTemplateDialog` edits only the per-cell template, the builder
7
+ * also collects the DATA — the value list (iterate) or the two value lists
8
+ * (pivot), the bind name, and the layout — and shows a LIVE preview of the
9
+ * populated block as you type. On save it writes a fully-bound directive
10
+ * (`serializeIterationDirective`) whose value lists live in its attributes, so the
11
+ * block renders populated via the built-in `evaluateEmbedded` and the `⋯` re-edit
12
+ * can reopen it losslessly (`parseIterationDirective` / `builderValueFromParts`).
13
+ *
14
+ * Controlled (`open` / `onOpenChange`); seed via `value` (re-edit) or `kind` +
15
+ * `initialValues` (fresh insert, e.g. a selection split to one value per line).
16
+ */
17
+ import {
18
+ Button,
19
+ Dialog,
20
+ DialogBody,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ Input,
27
+ Label,
28
+ TagInput,
29
+ ToggleGroup,
30
+ ToggleGroupItem,
31
+ } from "@elabs-ai/components-ui";
32
+ import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
33
+
34
+ import { type EvaluateCalc } from "../calc-block";
35
+ import { MarkdownPreview } from "../markdown-preview";
36
+ import { MarkdownWorkspace } from "../markdown-workspace";
37
+ import { IterationEditContext, type IterationEditRequest } from "./edit-context";
38
+ import { type InterpolateTemplate, type IterationLayout } from "./iteration";
39
+ import {
40
+ builderValueFromParts,
41
+ directivePartsFromValue,
42
+ emptyBuilderValue,
43
+ evaluateEmbedded,
44
+ ITERATION_LAYOUTS,
45
+ parseIterationDirective,
46
+ serializeIterationDirective,
47
+ type IterationBuilderValue,
48
+ } from "./iteration-builder";
49
+
50
+ export interface IterationBuilderDialogProps {
51
+ open: boolean;
52
+ onOpenChange: (open: boolean) => void;
53
+ /** Which block to author. Default `"iterate"`. Ignored when `value` is set. */
54
+ kind?: "iterate" | "pivot";
55
+ /** Seed the whole builder (the `⋯` re-edit path). */
56
+ value?: IterationBuilderValue;
57
+ /** Seed the value list of a fresh block (e.g. a selection split to lines). */
58
+ initialValues?: string[];
59
+ /** Receives the fully-bound directive markdown when the user saves. */
60
+ onSave: (directiveMarkdown: string) => void;
61
+ /**
62
+ * Resolve a ```calc fence to a `CalcSheet` so calc cells COMPUTE in the live
63
+ * preview (the same hook `MarkdownPreview` takes — the library renders, the app
64
+ * computes). Without it a calc cell still renders as a code block; with it, the
65
+ * preview matches production. Pass your app's calc engine.
66
+ */
67
+ evaluate?: EvaluateCalc;
68
+ /** Fill a cell template with its context. Defaults to `{{path}}` substitution. */
69
+ interpolate?: InterpolateTemplate;
70
+ }
71
+
72
+ export function IterationBuilderDialog({
73
+ open,
74
+ onOpenChange,
75
+ kind: kindProp = "iterate",
76
+ value,
77
+ initialValues,
78
+ onSave,
79
+ evaluate,
80
+ interpolate,
81
+ }: IterationBuilderDialogProps) {
82
+ const kind = value?.kind ?? kindProp;
83
+ const isPivot = kind === "pivot";
84
+ const ids = useId();
85
+
86
+ // Working draft. Value lists are arrays (chip entry via TagInput); the per-cell
87
+ // template stays raw text.
88
+ const [asName, setAsName] = useState("item");
89
+ const [layout, setLayout] = useState<IterationLayout>(ITERATION_LAYOUTS[kind][0] ?? "stacked");
90
+ const [values, setValues] = useState<string[]>([]);
91
+ const [cols, setCols] = useState<string[]>([]);
92
+ const [template, setTemplate] = useState("");
93
+
94
+ // (Re)seed whenever the dialog opens against a new value / kind.
95
+ useEffect(() => {
96
+ if (!open) return;
97
+ const seed = value ?? {
98
+ ...emptyBuilderValue(kind),
99
+ values: initialValues ?? [],
100
+ };
101
+ setAsName(seed.as);
102
+ setLayout(seed.layout);
103
+ setValues(seed.values);
104
+ setCols(seed.cols ?? []);
105
+ setTemplate(seed.template);
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [open]);
108
+
109
+ const draft: IterationBuilderValue = useMemo(
110
+ () => ({
111
+ kind,
112
+ as: asName.trim() || "item",
113
+ layout,
114
+ values,
115
+ cols: isPivot ? cols : undefined,
116
+ template,
117
+ }),
118
+ [kind, asName, layout, values, cols, template, isPivot],
119
+ );
120
+
121
+ // Live preview — render the SERIALIZED directive through MarkdownPreview, the
122
+ // exact path the saved block renders through. That resolves the iteration via
123
+ // the built-in `evaluateEmbedded` AND each cell's objects (a ```calc fence,
124
+ // nested directives, formatting) — in stacked, grid and matrix alike — instead
125
+ // of dumping the raw cell text.
126
+ const previewMarkdown = useMemo(() => serializeIterationDirective(draft), [draft]);
127
+
128
+ const save = () => {
129
+ onSave(serializeIterationDirective(draft));
130
+ onOpenChange(false);
131
+ };
132
+
133
+ const noun = isPivot ? "pivot" : "iteration";
134
+
135
+ return (
136
+ <Dialog open={open} onOpenChange={onOpenChange}>
137
+ <DialogContent className="flex max-h-[88vh] w-[min(56rem,94vw)] max-w-none flex-col">
138
+ <DialogHeader>
139
+ <DialogTitle>
140
+ {value ? "Edit" : "Insert"} {noun}
141
+ </DialogTitle>
142
+ <DialogDescription>
143
+ {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."}
146
+ </DialogDescription>
147
+ </DialogHeader>
148
+
149
+ <DialogBody className="grid gap-4 md:grid-cols-2">
150
+ {/* Left column — the DATA the block iterates over. */}
151
+ <div className="flex min-w-0 flex-col gap-4">
152
+ {!isPivot ? (
153
+ <div className="flex flex-col gap-1.5">
154
+ <Label htmlFor={`${ids}-as`}>Bind name</Label>
155
+ <Input
156
+ id={`${ids}-as`}
157
+ value={asName}
158
+ spellCheck={false}
159
+ autoComplete="off"
160
+ placeholder="item"
161
+ onChange={(e) => setAsName(e.target.value)}
162
+ />
163
+ <p className="text-meta text-muted-foreground">
164
+ Use <code>{`{{${asName.trim() || "item"}.name}}`}</code> in the template.
165
+ </p>
166
+ </div>
167
+ ) : null}
168
+
169
+ <div className="flex flex-col gap-1.5">
170
+ <Label htmlFor={`${ids}-values`}>{isPivot ? "Row values" : "Values"}</Label>
171
+ <TagInput
172
+ id={`${ids}-values`}
173
+ value={values}
174
+ onValueChange={setValues}
175
+ delimiter={[",", "\n"]}
176
+ placeholder="Type a value, press Enter…"
177
+ />
178
+ </div>
179
+
180
+ {isPivot ? (
181
+ <div className="flex flex-col gap-1.5">
182
+ <Label htmlFor={`${ids}-cols`}>Column values</Label>
183
+ <TagInput
184
+ id={`${ids}-cols`}
185
+ value={cols}
186
+ onValueChange={setCols}
187
+ delimiter={[",", "\n"]}
188
+ placeholder="Type a value, press Enter…"
189
+ />
190
+ </div>
191
+ ) : null}
192
+
193
+ <div className="flex flex-col gap-1.5">
194
+ <Label id={`${ids}-layout`}>Layout</Label>
195
+ <ToggleGroup
196
+ type="single"
197
+ variant="segmented"
198
+ value={layout}
199
+ onValueChange={(next) => {
200
+ // Single-select: ignore the empty value Radix emits when the active
201
+ // item is re-pressed, so a layout is always selected.
202
+ if (next) setLayout(next as IterationLayout);
203
+ }}
204
+ aria-labelledby={`${ids}-layout`}
205
+ className="w-fit"
206
+ >
207
+ {ITERATION_LAYOUTS[kind].map((l) => (
208
+ <ToggleGroupItem key={l} value={l} className="capitalize">
209
+ {l}
210
+ </ToggleGroupItem>
211
+ ))}
212
+ </ToggleGroup>
213
+ </div>
214
+ </div>
215
+
216
+ {/* Right column — the per-cell TEMPLATE + the live populated preview. */}
217
+ <div className="flex min-h-0 min-w-0 flex-col gap-4">
218
+ <div className="flex min-h-0 flex-col gap-1.5">
219
+ <Label>Per-{isPivot ? "cell" : "row"} template</Label>
220
+ <div className="h-44 min-h-0 overflow-hidden rounded-md border border-border">
221
+ <MarkdownWorkspace
222
+ value={template}
223
+ onChange={setTemplate}
224
+ defaultMode="source"
225
+ className="h-full"
226
+ aria-label="Per-cell template"
227
+ />
228
+ </div>
229
+ </div>
230
+
231
+ <div className="flex min-h-0 flex-col gap-1.5">
232
+ <Label>Live preview</Label>
233
+ <div
234
+ role="region"
235
+ aria-label="Live preview"
236
+ className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-card p-3"
237
+ >
238
+ <MarkdownPreview
239
+ evaluateIteration={evaluateEmbedded}
240
+ evaluate={evaluate}
241
+ interpolate={interpolate}
242
+ >
243
+ {previewMarkdown}
244
+ </MarkdownPreview>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </DialogBody>
249
+
250
+ <DialogFooter>
251
+ <Button variant="ghost" onClick={() => onOpenChange(false)}>
252
+ Cancel
253
+ </Button>
254
+ <Button onClick={save}>{value ? "Save" : "Insert"}</Button>
255
+ </DialogFooter>
256
+ </DialogContent>
257
+ </Dialog>
258
+ );
259
+ }
260
+
261
+ /**
262
+ * One-liner wiring for the node-view `⋯` re-edit using the GUIDED builder (A5).
263
+ * Drop it above a `MarkdownEditor` / `MarkdownWorkspace` to make the `⋯` on a
264
+ * `:::iterate` / `:::pivot` reopen the full builder seeded with the block's DATA
265
+ * (value lists + bind name + layout) and template, writing both back losslessly.
266
+ *
267
+ * Prefer this over {@link IterationTemplateProvider} when the consumer authors
268
+ * blocks with embedded value lists (the builder flow); the template-only provider
269
+ * remains for the lighter "edit just the template" affordance.
270
+ */
271
+ export function IterationBuilderProvider({
272
+ children,
273
+ evaluate,
274
+ interpolate,
275
+ }: {
276
+ children: ReactNode;
277
+ /** Forwarded to the builder's live preview so calc cells COMPUTE on re-edit. */
278
+ evaluate?: EvaluateCalc;
279
+ /** Forwarded to the builder's live preview cell interpolation. */
280
+ interpolate?: InterpolateTemplate;
281
+ }) {
282
+ const [request, setRequest] = useState<IterationEditRequest | null>(null);
283
+
284
+ const seed = request
285
+ ? builderValueFromParts(request.kind, request.attributes ?? {}, request.template)
286
+ : undefined;
287
+
288
+ return (
289
+ <IterationEditContext.Provider value={setRequest}>
290
+ {children}
291
+ <IterationBuilderDialog
292
+ open={request != null}
293
+ onOpenChange={(next) => {
294
+ if (!next) setRequest(null);
295
+ }}
296
+ kind={request?.kind ?? "iterate"}
297
+ value={seed}
298
+ evaluate={evaluate}
299
+ interpolate={interpolate}
300
+ onSave={(directiveMarkdown) => {
301
+ const parsed = parseIterationDirective(directiveMarkdown);
302
+ if (!parsed) return;
303
+ const { attributes, template } = directivePartsFromValue(parsed);
304
+ // Prefer the data-aware writer (rewrites attributes + body); fall back to
305
+ // the template-only writer for older node-views.
306
+ if (request?.onSaveData) request.onSaveData({ attributes, template });
307
+ else request?.onSave(template);
308
+ }}
309
+ />
310
+ </IterationEditContext.Provider>
311
+ );
312
+ }