@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.
- package/README.md +6 -7
- package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
- package/dist/chunk-FO5S3YZM.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +112 -57
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.css +4 -2
- package/dist/markdown/index.css.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +268 -191
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
- package/dist/monaco.d.ts +2 -0
- package/dist/monaco.js +9 -0
- package/dist/monaco.js.map +1 -0
- package/package.json +10 -6
- package/src/ai-objects/decision-card.tsx +15 -9
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +18 -9
- package/src/barrel-monaco-lazy.test.ts +50 -0
- package/src/calc-block/calc-block.tsx +10 -4
- package/src/calc-block/calc-editor.css +1 -1
- package/src/code-editor/code-editor.stories.tsx +76 -1
- package/src/code-editor/code-editor.test.tsx +141 -14
- package/src/code-editor/code-editor.tsx +167 -36
- package/src/code-workspace/code-workspace.test.tsx +113 -11
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/copy-button/copy-button.tsx +6 -3
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- package/src/diff-editor/diff-editor.test.tsx +9 -3
- package/src/diff-editor/diff-editor.tsx +47 -23
- package/src/editor-toolbar/editor-toolbar.tsx +8 -2
- package/src/index.ts +4 -3
- package/src/lib/monaco-deep-imports.d.ts +59 -0
- package/src/lib/monaco-theme-bridge.test.ts +307 -0
- package/src/lib/monaco-theme-bridge.ts +154 -10
- package/src/markdown-academic/citations.tsx +14 -7
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/math.tsx +7 -4
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/completions/completions-menu.tsx +5 -2
- package/src/markdown-editor/directive-views.tsx +35 -24
- package/src/markdown-editor/markdown-editor.css +26 -3
- package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
- package/src/markdown-editor/markdown-editor.tsx +8 -5
- package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
- package/src/markdown-editor/paste-embed.ts +2 -1
- package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
- package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
- package/src/markdown-editor/slash/slash-menu.tsx +18 -3
- package/src/markdown-editor/table-view.tsx +31 -17
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
- package/src/markdown-iteration/template-dialog.tsx +25 -7
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +7 -3
- package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
- package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
- package/src/markdown-preview/markdown-preview.test.tsx +20 -3
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
- package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
- package/src/markdown-workspace/markdown-workspace.tsx +6 -3
- package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
- package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
- package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
- package/src/monaco.ts +21 -0
- package/src/prose/prose.stories.tsx +3 -0
- package/src/prose/prose.test.ts +54 -0
- package/src/prose/prose.tsx +7 -0
- 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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
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-
|
|
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="
|
|
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">
|
|
164
|
+
<span className="mr-1 select-none text-caption text-muted-foreground">
|
|
165
|
+
{t("editor.tableView.row")}
|
|
166
|
+
</span>
|
|
155
167
|
<ToolbarButton
|
|
156
|
-
ariaLabel=
|
|
157
|
-
title=
|
|
168
|
+
ariaLabel={addRowAbove}
|
|
169
|
+
title={addRowAbove}
|
|
158
170
|
onClick={run(addRowBeforeCommand.key)}
|
|
159
171
|
>
|
|
160
172
|
↑+
|
|
161
173
|
</ToolbarButton>
|
|
162
174
|
<ToolbarButton
|
|
163
|
-
ariaLabel=
|
|
164
|
-
title=
|
|
175
|
+
ariaLabel={addRowBelow}
|
|
176
|
+
title={addRowBelow}
|
|
165
177
|
onClick={run(addRowAfterCommand.key)}
|
|
166
178
|
>
|
|
167
179
|
↓+
|
|
168
180
|
</ToolbarButton>
|
|
169
181
|
<ToolbarButton
|
|
170
|
-
ariaLabel=
|
|
171
|
-
title=
|
|
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">
|
|
193
|
+
<span className="mr-1 select-none text-caption text-muted-foreground">
|
|
194
|
+
{t("editor.tableView.col")}
|
|
195
|
+
</span>
|
|
182
196
|
<ToolbarButton
|
|
183
|
-
ariaLabel=
|
|
184
|
-
title=
|
|
197
|
+
ariaLabel={addColLeft}
|
|
198
|
+
title={addColLeft}
|
|
185
199
|
onClick={run(addColBeforeCommand.key)}
|
|
186
200
|
>
|
|
187
201
|
←+
|
|
188
202
|
</ToolbarButton>
|
|
189
203
|
<ToolbarButton
|
|
190
|
-
ariaLabel=
|
|
191
|
-
title=
|
|
204
|
+
ariaLabel={addColRight}
|
|
205
|
+
title={addColRight}
|
|
192
206
|
onClick={run(addColAfterCommand.key)}
|
|
193
207
|
>
|
|
194
208
|
→+
|
|
195
209
|
</ToolbarButton>
|
|
196
210
|
<ToolbarButton
|
|
197
|
-
ariaLabel=
|
|
198
|
-
title=
|
|
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 =
|
|
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
|
|
114
|
-
await
|
|
115
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
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
|
|
252
|
-
await
|
|
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
|
-
|
|
323
|
+
const preview = await within(dialog).findByRole(
|
|
324
|
+
"region",
|
|
325
|
+
{ name: /live preview/i },
|
|
326
|
+
{ timeout: 8000 },
|
|
289
327
|
);
|
|
290
|
-
await
|
|
291
|
-
await
|
|
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
|
|
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
|
-
{
|
|
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
|
-
? "
|
|
145
|
-
: "
|
|
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`}>
|
|
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="
|
|
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
|
-
|
|
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`}>
|
|
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="
|
|
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`}>
|
|
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="
|
|
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`}>
|
|
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>
|
|
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="
|
|
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>
|
|
251
|
+
<Label>{t("editor.iterationBuilder.livePreview")}</Label>
|
|
233
252
|
<div
|
|
234
253
|
role="region"
|
|
235
|
-
aria-label="
|
|
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
|
-
|
|
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
|
|
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>
|
|
79
|
+
<DialogTitle>
|
|
80
|
+
{kind === "pivot"
|
|
81
|
+
? t("editor.templateDialog.editPivotTitle")
|
|
82
|
+
: t("editor.templateDialog.editIterationTitle")}
|
|
83
|
+
</DialogTitle>
|
|
69
84
|
<DialogDescription>
|
|
70
|
-
|
|
71
|
-
<code>{"{{
|
|
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="
|
|
98
|
+
aria-label={t("editor.templateDialog.editorLabel")}
|
|
81
99
|
/>
|
|
82
100
|
</div>
|
|
83
101
|
<DialogFooter>
|
|
84
102
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
85
|
-
|
|
103
|
+
{t("editor.templateDialog.cancel")}
|
|
86
104
|
</Button>
|
|
87
|
-
<Button onClick={save}>
|
|
105
|
+
<Button onClick={save}>{t("editor.templateDialog.saveTemplate")}</Button>
|
|
88
106
|
</DialogFooter>
|
|
89
107
|
</DialogContent>
|
|
90
108
|
</Dialog>
|