@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor, within } from "storybook/test";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import { ThemeProvider } from "@elabs-ai/components-tokens";
|
|
5
|
+
import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
|
|
6
|
+
import { IterationBuilderProvider } from "../markdown-iteration/iteration-builder-dialog";
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "Editor/MarkdownEditor",
|
|
10
|
+
component: MarkdownEditor,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
parameters: {
|
|
13
|
+
layout: "fullscreen",
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component:
|
|
17
|
+
"A headless Milkdown (ProseMirror) WYSIWYG markdown surface vendored onto " +
|
|
18
|
+
"brand-ui. Companion to the Monaco `CodeEditor` (source) in the same package. " +
|
|
19
|
+
"Theming is 100% semantic tokens, so it tracks all three themes via `data-theme`. " +
|
|
20
|
+
"Depends on only `@milkdown/kit` — no Vue/Crepe.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof MarkdownEditor>;
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof meta>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Post-condition every node-menu play owes (#386). A Radix MODAL menu marks the
|
|
31
|
+
* rest of the document `aria-hidden` while it is open, so a play that returns
|
|
32
|
+
* with one still up (or still tearing down) fails axe's `aria-hidden-focus` —
|
|
33
|
+
* and the marker survives into the NEXT story in this file, where `findByRole`
|
|
34
|
+
* then can't see the `⋯` button at all ("Unable to find role=button and name
|
|
35
|
+
* 'Iteration actions'") even though `findByText` still matches, because only
|
|
36
|
+
* role queries consult the accessibility tree. Waiting on the marker is the
|
|
37
|
+
* behavioural assertion "dismissing the menu restores the document".
|
|
38
|
+
*/
|
|
39
|
+
async function expectDocumentRestored(canvasElement: HTMLElement) {
|
|
40
|
+
const doc = canvasElement.ownerDocument;
|
|
41
|
+
await waitFor(() => expect(within(doc.body).queryByRole("menu")).toBeNull());
|
|
42
|
+
await waitFor(() => expect(doc.querySelector('[data-aria-hidden="true"]')).toBeNull());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SAMPLE = `# Customer Objective
|
|
46
|
+
|
|
47
|
+
Migrate **1,245 reports** to the new platform by Q3. Track scope, owners and risk.
|
|
48
|
+
|
|
49
|
+
## Checklist
|
|
50
|
+
|
|
51
|
+
- [x] Freeze scope
|
|
52
|
+
- [ ] Map data sources
|
|
53
|
+
- [ ] Pilot with finance
|
|
54
|
+
|
|
55
|
+
> Frozen scope candidates are the migration baseline.
|
|
56
|
+
|
|
57
|
+
| Phase | Owner | Status |
|
|
58
|
+
| ------ | ------- | ------ |
|
|
59
|
+
| Draft | Manuel | done |
|
|
60
|
+
| Review | Team | active |
|
|
61
|
+
|
|
62
|
+
Inline \`code\`, a [link](https://example.com), and a divider:
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
\`\`\`ts
|
|
67
|
+
export const scope = 1245;
|
|
68
|
+
\`\`\`
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
export const Default: Story = {
|
|
72
|
+
render: () => (
|
|
73
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
74
|
+
<MarkdownEditor defaultValue={SAMPLE} />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
play: async ({ canvasElement }) => {
|
|
78
|
+
// Milkdown mounts asynchronously — wait for the rendered heading.
|
|
79
|
+
const canvas = within(canvasElement);
|
|
80
|
+
await expect(
|
|
81
|
+
await canvas.findByText("Customer Objective", {}, { timeout: 8000 }),
|
|
82
|
+
).toBeVisible();
|
|
83
|
+
// #21: the contenteditable role="textbox" surface must have an accessible name
|
|
84
|
+
// (axe `aria-input-field-name`). Defaults to "Markdown editor".
|
|
85
|
+
await expect(
|
|
86
|
+
await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
|
|
87
|
+
).toBeVisible();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const Controlled: Story = {
|
|
92
|
+
render: function ControlledStory() {
|
|
93
|
+
const [value, setValue] = useState("# Type here\n\nEdits flow through `onChange`.");
|
|
94
|
+
return (
|
|
95
|
+
<div className="mx-auto flex max-w-3xl flex-col gap-3 p-6">
|
|
96
|
+
<MarkdownEditor value={value} onChange={setValue} />
|
|
97
|
+
<p className="text-meta text-muted-foreground">{value.length} characters</p>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const ReadOnly: Story = {
|
|
104
|
+
name: "Read-only",
|
|
105
|
+
render: () => (
|
|
106
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
107
|
+
<MarkdownEditor defaultValue={SAMPLE} readOnly />
|
|
108
|
+
</div>
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const DIRECTIVES = `# Migration plan
|
|
113
|
+
|
|
114
|
+
:::card{title="Objective"}
|
|
115
|
+
|
|
116
|
+
Migrate the reporting estate to the new platform by Q3.
|
|
117
|
+
|
|
118
|
+
:::
|
|
119
|
+
|
|
120
|
+
:::callout{type="warning" title="At risk"}
|
|
121
|
+
|
|
122
|
+
Two upstream data sources are still un-mapped.
|
|
123
|
+
|
|
124
|
+
:::
|
|
125
|
+
|
|
126
|
+
::metric{label="Reports in scope" value="1,245" description="across 9 domains" delta="+12%"}
|
|
127
|
+
|
|
128
|
+
:::timeline
|
|
129
|
+
|
|
130
|
+
- (done) Freeze scope
|
|
131
|
+
- (active) Map data sources
|
|
132
|
+
- (pending) Pilot with finance
|
|
133
|
+
|
|
134
|
+
:::
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The brand `:::` directives render as the REAL @brand components inside the editor
|
|
139
|
+
* (a live `Card`, `Alert`, `MetricBlock`), with the card/callout title and the metric
|
|
140
|
+
* label/value editable inline — not the old token-styled `toDOM` chrome. Switch the
|
|
141
|
+
* Storybook theme to confirm they track all three themes.
|
|
142
|
+
*/
|
|
143
|
+
export const LiveDirectives: Story = {
|
|
144
|
+
name: "Live directives (inline-editable)",
|
|
145
|
+
render: () => (
|
|
146
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
147
|
+
<MarkdownEditor defaultValue={DIRECTIVES} />
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
play: async ({ canvasElement }) => {
|
|
151
|
+
const canvas = within(canvasElement);
|
|
152
|
+
// The directive node-views expose their editable attributes as textboxes.
|
|
153
|
+
await expect(
|
|
154
|
+
await canvas.findByRole("textbox", { name: "Card title" }, { timeout: 8000 }),
|
|
155
|
+
).toBeVisible();
|
|
156
|
+
await expect(canvas.getByRole("textbox", { name: "Metric value" })).toHaveTextContent("1,245");
|
|
157
|
+
// The callout renders as a real Alert (role="alert").
|
|
158
|
+
await expect(canvas.getByRole("alert")).toBeVisible();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Demonstrates `onEmbedAsset` — the paste/drop image-embed hook.
|
|
164
|
+
*
|
|
165
|
+
* The mock hook simulates a 600 ms upload delay and resolves with a data-URL
|
|
166
|
+
* so no real server is needed. To exercise it manually:
|
|
167
|
+
* 1. Copy an image file to your clipboard (or drag one onto the editor).
|
|
168
|
+
* 2. Paste / drop into the editor surface.
|
|
169
|
+
* 3. You will see the "Uploading…" chip appear, then the image render inline.
|
|
170
|
+
* 4. The markdown output below shows ``.
|
|
171
|
+
*
|
|
172
|
+
* The error variant is toggled by the "Simulate failure" button — it wires a
|
|
173
|
+
* hook that always rejects so you can observe the inline error chip + toast.
|
|
174
|
+
*
|
|
175
|
+
* Theme sweep: switch the Storybook theme (toolbar) to verify the upload chip
|
|
176
|
+
* and error chip use only semantic tokens across light/dark.
|
|
177
|
+
*/
|
|
178
|
+
export const PasteEmbed: Story = {
|
|
179
|
+
name: "Paste / drop image embed (onEmbedAsset)",
|
|
180
|
+
render: function PasteEmbedStory() {
|
|
181
|
+
const ref = useRef<MarkdownEditorHandle>(null);
|
|
182
|
+
const [markdown, setMarkdown] = useState("# Image embed demo\n\nPaste or drop an image here.");
|
|
183
|
+
const [simulateFailure, setSimulateFailure] = useState(false);
|
|
184
|
+
const [lastPath, setLastPath] = useState<string | null>(null);
|
|
185
|
+
|
|
186
|
+
const onEmbedAsset = async (file: File): Promise<string> => {
|
|
187
|
+
if (simulateFailure) {
|
|
188
|
+
await new Promise((_, reject) =>
|
|
189
|
+
setTimeout(() => reject(new Error("Simulated upload failure")), 600),
|
|
190
|
+
);
|
|
191
|
+
throw new Error("Simulated upload failure");
|
|
192
|
+
}
|
|
193
|
+
// Simulate upload delay: read file as data-URL and return it.
|
|
194
|
+
return new Promise<string>((resolve, reject) => {
|
|
195
|
+
const reader = new FileReader();
|
|
196
|
+
reader.onload = () => {
|
|
197
|
+
const path = `/demo/uploads/${Date.now()}-${file.name}`;
|
|
198
|
+
setLastPath(path);
|
|
199
|
+
resolve(path);
|
|
200
|
+
};
|
|
201
|
+
reader.onerror = () => reject(new Error("FileReader error"));
|
|
202
|
+
reader.readAsDataURL(file);
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className="mx-auto flex max-w-3xl flex-col gap-3 p-6">
|
|
208
|
+
<div className="flex items-center gap-3">
|
|
209
|
+
<label className="flex items-center gap-2 text-caption text-muted-foreground">
|
|
210
|
+
<input
|
|
211
|
+
type="checkbox"
|
|
212
|
+
checked={simulateFailure}
|
|
213
|
+
onChange={(e) => setSimulateFailure(e.target.checked)}
|
|
214
|
+
/>
|
|
215
|
+
Simulate upload failure
|
|
216
|
+
</label>
|
|
217
|
+
{lastPath && (
|
|
218
|
+
<span className="text-caption text-muted-foreground">
|
|
219
|
+
Last resolved path: <code>{lastPath}</code>
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
<MarkdownEditor
|
|
224
|
+
ref={ref}
|
|
225
|
+
value={markdown}
|
|
226
|
+
onChange={setMarkdown}
|
|
227
|
+
onEmbedAsset={onEmbedAsset}
|
|
228
|
+
className="min-h-[200px]"
|
|
229
|
+
/>
|
|
230
|
+
<details>
|
|
231
|
+
<summary className="cursor-pointer text-caption text-muted-foreground">
|
|
232
|
+
Serialized markdown
|
|
233
|
+
</summary>
|
|
234
|
+
<pre className="mt-1 overflow-auto rounded bg-muted p-3 text-caption">{markdown}</pre>
|
|
235
|
+
</details>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
play: async ({ canvasElement }) => {
|
|
240
|
+
const canvas = within(canvasElement);
|
|
241
|
+
// Wait for the editor to boot.
|
|
242
|
+
await expect(
|
|
243
|
+
await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
|
|
244
|
+
).toBeVisible();
|
|
245
|
+
// The editor surface is ready; paste/drop interaction requires a real
|
|
246
|
+
// ClipboardEvent with files, which Storybook interaction tests can't
|
|
247
|
+
// synthesize reliably across browsers. Verify the surface is accessible.
|
|
248
|
+
await expect(canvas.getByRole("textbox", { name: "Markdown editor" })).toBeVisible();
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** GFM table seed for the EditableTable story. */
|
|
253
|
+
const TABLE_DOC = `# Project status
|
|
254
|
+
|
|
255
|
+
Click inside any table cell to reveal the **Table controls** toolbar below the
|
|
256
|
+
editor. Use it to add or remove rows and columns. Tab / Shift+Tab move between
|
|
257
|
+
cells. The table serializes back to lossless GFM markdown.
|
|
258
|
+
|
|
259
|
+
| Phase | Owner | Status | Due |
|
|
260
|
+
| -------- | ------- | ------- | ------- |
|
|
261
|
+
| Planning | Alice | done | 2024-Q1 |
|
|
262
|
+
| Build | Bob | active | 2024-Q2 |
|
|
263
|
+
| Review | Charlie | pending | 2024-Q3 |
|
|
264
|
+
|
|
265
|
+
Prose continues normally after the table.
|
|
266
|
+
`;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Demonstrates the table node-view feature:
|
|
270
|
+
*
|
|
271
|
+
* - Click inside a table cell → the **Table controls** toolbar appears at the
|
|
272
|
+
* bottom of the editor surface.
|
|
273
|
+
* - Toolbar buttons: Add row above/below, Delete row, Add col left/right,
|
|
274
|
+
* Delete column — all aria-labelled, keyboard-operable.
|
|
275
|
+
* - Tab moves to the next cell; Shift+Tab to the previous.
|
|
276
|
+
* - The serialized markdown (shown below the editor) stays lossless GFM.
|
|
277
|
+
*
|
|
278
|
+
* Three-theme sweep: switch the Storybook theme toolbar to verify the table
|
|
279
|
+
* borders (`border-border-strong`), header tint (`bg-surface-muted`), and
|
|
280
|
+
* toolbar chrome use only semantic tokens across all three themes.
|
|
281
|
+
*/
|
|
282
|
+
export const EditableTable: Story = {
|
|
283
|
+
name: "Editable table (wysiwyg-tables)",
|
|
284
|
+
render: function EditableTableStory() {
|
|
285
|
+
const ref = useRef<MarkdownEditorHandle>(null);
|
|
286
|
+
const [markdown, setMarkdown] = useState(TABLE_DOC);
|
|
287
|
+
return (
|
|
288
|
+
<div className="mx-auto flex max-w-3xl flex-col gap-3 p-6">
|
|
289
|
+
<MarkdownEditor
|
|
290
|
+
ref={ref}
|
|
291
|
+
value={markdown}
|
|
292
|
+
onChange={setMarkdown}
|
|
293
|
+
className="min-h-[280px]"
|
|
294
|
+
/>
|
|
295
|
+
<details>
|
|
296
|
+
<summary className="cursor-pointer text-caption text-muted-foreground">
|
|
297
|
+
Serialized markdown (GFM round-trip)
|
|
298
|
+
</summary>
|
|
299
|
+
<pre className="mt-1 overflow-auto rounded bg-muted p-3 text-caption">{markdown}</pre>
|
|
300
|
+
</details>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
},
|
|
304
|
+
play: async ({ canvasElement }) => {
|
|
305
|
+
const canvas = within(canvasElement);
|
|
306
|
+
// Wait for the editor to boot and the table to render.
|
|
307
|
+
await expect(
|
|
308
|
+
await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
|
|
309
|
+
).toBeVisible();
|
|
310
|
+
// The GFM table must render as a real <table> element in the WYSIWYG.
|
|
311
|
+
await expect(await canvas.findByRole("table", {}, { timeout: 8000 })).toBeVisible();
|
|
312
|
+
// The table cells are accessible (role="cell" / "columnheader").
|
|
313
|
+
const cells = canvas.getAllByRole("cell");
|
|
314
|
+
expect(cells.length).toBeGreaterThan(0);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Fill height (editor-fill-height): in a definite-height container, the editable
|
|
320
|
+
* must fill the whole pane — not just its content. A short (1-line) document
|
|
321
|
+
* still produces a full-height clickable surface, and the caret-anchored `/` menu
|
|
322
|
+
* has room to open near the bottom edge. Before the fix the host was
|
|
323
|
+
* content-sized (`min-height: 9rem` ≈ 144px), leaving a large dead area below the
|
|
324
|
+
* editable into which the slash menu clipped.
|
|
325
|
+
*/
|
|
326
|
+
export const FillsContainer: Story = {
|
|
327
|
+
name: "Fills container (editor-fill-height)",
|
|
328
|
+
render: () => (
|
|
329
|
+
// A definite-height parent (the workspace's `h-full` wysiwyg pane in the real
|
|
330
|
+
// app). No `h-full` on the editor itself — it fills via `min-height: 100%`.
|
|
331
|
+
<div style={{ height: 700 }}>
|
|
332
|
+
<MarkdownEditor defaultValue={"# One line"} aria-label="Markdown editor" />
|
|
333
|
+
</div>
|
|
334
|
+
),
|
|
335
|
+
play: async ({ canvasElement }) => {
|
|
336
|
+
const canvas = within(canvasElement);
|
|
337
|
+
const editable = await canvas.findByRole(
|
|
338
|
+
"textbox",
|
|
339
|
+
{ name: "Markdown editor" },
|
|
340
|
+
{ timeout: 8000 },
|
|
341
|
+
);
|
|
342
|
+
await expect(editable).toBeVisible();
|
|
343
|
+
// The 1-line doc's editable fills the 700px container (well past the ~144px
|
|
344
|
+
// content floor) — clickable everywhere, with room for the `/` menu.
|
|
345
|
+
expect(editable.clientHeight).toBeGreaterThan(500);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Code-block exit (A3): the caret can escape a fenced code block — including the
|
|
351
|
+
* trailing ```calc fence — with **Tab** or **Mod-Enter**. A code block that is
|
|
352
|
+
* the document's last child no longer traps the caret; exiting inserts a new
|
|
353
|
+
* paragraph below it. The behavior is unit-locked in `exit-keymap.test.ts`; this
|
|
354
|
+
* story is the manual playground — click inside the block and press Tab.
|
|
355
|
+
*/
|
|
356
|
+
export const CodeBlockExit: Story = {
|
|
357
|
+
name: "Code-block exit (Tab / Mod-Enter)",
|
|
358
|
+
render: () => (
|
|
359
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
360
|
+
<MarkdownEditor
|
|
361
|
+
defaultValue={
|
|
362
|
+
"Click inside the block below and press **Tab** to escape it.\n\n" +
|
|
363
|
+
"```calc\nrevenue = 1200\ncost = 800\nprofit = revenue - cost\n```\n"
|
|
364
|
+
}
|
|
365
|
+
aria-label="Markdown editor"
|
|
366
|
+
/>
|
|
367
|
+
</div>
|
|
368
|
+
),
|
|
369
|
+
play: async ({ canvasElement }) => {
|
|
370
|
+
const canvas = within(canvasElement);
|
|
371
|
+
await expect(
|
|
372
|
+
await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
|
|
373
|
+
).toBeVisible();
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const ITERATE_NODE_MENU_SEED = `# Node menu demo
|
|
378
|
+
|
|
379
|
+
:::iterate{as="item" layout="stacked" values="Alice, Bob"}
|
|
380
|
+
{{item.name}}
|
|
381
|
+
:::
|
|
382
|
+
`;
|
|
383
|
+
|
|
384
|
+
const PIVOT_NODE_MENU_SEED = `# Pivot node menu demo
|
|
385
|
+
|
|
386
|
+
:::pivot{layout="matrix" rows="Q1, Q2" cols="North, South"}
|
|
387
|
+
{{row}} · {{col}}
|
|
388
|
+
:::
|
|
389
|
+
`;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The iteration node-view's menu (#223): a `⋯` button opens "Edit iteration…"
|
|
393
|
+
* (the guided re-edit), "Change layout" (a submenu of radio items — stacked /
|
|
394
|
+
* grid / bento for `iterate` — that rewrites the `layout` attribute directly,
|
|
395
|
+
* no dialog), and "Convert to static" (replaces the directive with its
|
|
396
|
+
* populated markdown; no ProseMirror dependency). `IterationBuilderProvider`
|
|
397
|
+
* wires the `⋯`/right-click affordance on; without it neither menu renders.
|
|
398
|
+
* The `ContextMenu` story below opens the SAME item set via right-click — both
|
|
399
|
+
* read one shared list (`IterationMenuItems` in directive-views.tsx) so they
|
|
400
|
+
* can never diverge.
|
|
401
|
+
*/
|
|
402
|
+
export const NodeMenu: Story = {
|
|
403
|
+
name: "Iteration node menu (⋯ dropdown)",
|
|
404
|
+
render: function NodeMenuStory() {
|
|
405
|
+
const [markdown, setMarkdown] = useState(ITERATE_NODE_MENU_SEED);
|
|
406
|
+
return (
|
|
407
|
+
<IterationBuilderProvider>
|
|
408
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
409
|
+
<MarkdownEditor value={markdown} onChange={setMarkdown} aria-label="Markdown editor" />
|
|
410
|
+
</div>
|
|
411
|
+
<pre className="mx-auto max-w-3xl whitespace-pre-wrap px-6 text-caption text-muted-foreground">
|
|
412
|
+
{markdown}
|
|
413
|
+
</pre>
|
|
414
|
+
</IterationBuilderProvider>
|
|
415
|
+
);
|
|
416
|
+
},
|
|
417
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
418
|
+
await expect(await canvas.findByText("Iterate", {}, { timeout: 8000 })).toBeVisible();
|
|
419
|
+
|
|
420
|
+
const menuButton = await canvas.findByRole(
|
|
421
|
+
"button",
|
|
422
|
+
{ name: "Iteration actions" },
|
|
423
|
+
{ timeout: 8000 },
|
|
424
|
+
);
|
|
425
|
+
await userEvent.click(menuButton);
|
|
426
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
427
|
+
const menu = await body.findByRole("menu", {}, { timeout: 8000 });
|
|
428
|
+
await waitFor(() => expect(within(menu).getByText("Edit iteration…")).toBeVisible());
|
|
429
|
+
await expect(within(menu).getByText("Change layout")).toBeVisible();
|
|
430
|
+
await expect(within(menu).getByText("Convert to static")).toBeVisible();
|
|
431
|
+
// `iterate` has no second axis — "Transpose" (pivot-only) never appears for it.
|
|
432
|
+
expect(within(menu).queryByText("Transpose")).toBeNull();
|
|
433
|
+
|
|
434
|
+
// "Change layout" → hover opens its submenu (Radix's pointer path); pick
|
|
435
|
+
// "grid" — rewrites the `layout` attribute directly, no dialog.
|
|
436
|
+
await userEvent.hover(within(menu).getByText("Change layout"));
|
|
437
|
+
const gridOption = await body.findByRole("menuitemradio", { name: "grid" }, { timeout: 8000 });
|
|
438
|
+
await waitFor(() => expect(gridOption).toBeVisible());
|
|
439
|
+
await userEvent.click(gridOption);
|
|
440
|
+
await waitFor(() =>
|
|
441
|
+
expect(canvas.getByText(/layout="grid"/, { exact: false })).toBeInTheDocument(),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Re-open the menu (selecting a radio item closes it AND the controlled
|
|
445
|
+
// `value` round-trip remounts the node-view, so re-query the button fresh
|
|
446
|
+
// rather than reuse the now-detached reference) and "Convert to static" —
|
|
447
|
+
// the directive is replaced with its populated markdown, so the branded
|
|
448
|
+
// "Iterate" frame disappears entirely.
|
|
449
|
+
const menuButton2 = await canvas.findByRole(
|
|
450
|
+
"button",
|
|
451
|
+
{ name: "Iteration actions" },
|
|
452
|
+
{ timeout: 8000 },
|
|
453
|
+
);
|
|
454
|
+
await userEvent.click(menuButton2);
|
|
455
|
+
const menu2 = await body.findByRole("menu", {}, { timeout: 8000 });
|
|
456
|
+
await waitFor(() => expect(within(menu2).getByText("Convert to static")).toBeVisible());
|
|
457
|
+
await userEvent.click(within(menu2).getByText("Convert to static"));
|
|
458
|
+
await waitFor(() => expect(canvas.queryByText("Iterate")).toBeNull());
|
|
459
|
+
await waitFor(() => expect(canvas.queryByText(/:::iterate/, { exact: false })).toBeNull());
|
|
460
|
+
await expectDocumentRestored(canvasElement);
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The SAME iteration node menu opens on right-click — the AC's "never
|
|
466
|
+
* right-click-only" cuts both ways: the `⋯` button (story above) and the
|
|
467
|
+
* context menu (here) must expose IDENTICAL actions. Uses a `:::pivot` with
|
|
468
|
+
* real row/column values so "Transpose" (pivot-only) has data to swap.
|
|
469
|
+
*/
|
|
470
|
+
export const IterationContextMenu: Story = {
|
|
471
|
+
name: "Iteration node menu (right-click)",
|
|
472
|
+
render: function IterationContextMenuStory() {
|
|
473
|
+
const [markdown, setMarkdown] = useState(PIVOT_NODE_MENU_SEED);
|
|
474
|
+
return (
|
|
475
|
+
<IterationBuilderProvider>
|
|
476
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
477
|
+
<MarkdownEditor value={markdown} onChange={setMarkdown} aria-label="Markdown editor" />
|
|
478
|
+
</div>
|
|
479
|
+
<pre className="mx-auto max-w-3xl whitespace-pre-wrap px-6 text-caption text-muted-foreground">
|
|
480
|
+
{markdown}
|
|
481
|
+
</pre>
|
|
482
|
+
</IterationBuilderProvider>
|
|
483
|
+
);
|
|
484
|
+
},
|
|
485
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
486
|
+
const pivotLabel = await canvas.findByText("Pivot", {}, { timeout: 8000 });
|
|
487
|
+
// Right-click anywhere in the directive frame — not just the ⋯ button.
|
|
488
|
+
await userEvent.pointer({ target: pivotLabel, keys: "[MouseRight]" });
|
|
489
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
490
|
+
const menu = await body.findByRole("menu", {}, { timeout: 8000 });
|
|
491
|
+
await waitFor(() => expect(within(menu).getByText("Edit iteration…")).toBeVisible());
|
|
492
|
+
await expect(within(menu).getByText("Change layout")).toBeVisible();
|
|
493
|
+
await expect(within(menu).getByText("Transpose")).toBeVisible();
|
|
494
|
+
await expect(within(menu).getByText("Convert to static")).toBeVisible();
|
|
495
|
+
|
|
496
|
+
await userEvent.click(within(menu).getByText("Transpose"));
|
|
497
|
+
// Rows/cols are swapped losslessly (Q1,Q2 ⇄ North,South).
|
|
498
|
+
await waitFor(() =>
|
|
499
|
+
expect(canvas.getByText(/rows="North, South"/, { exact: false })).toBeInTheDocument(),
|
|
500
|
+
);
|
|
501
|
+
expect(canvas.getByText(/cols="Q1, Q2"/, { exact: false })).toBeInTheDocument();
|
|
502
|
+
await expectDocumentRestored(canvasElement);
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Three-theme sweep for the node menu (#223 round-2): both `NodeMenu` (⋯) and
|
|
508
|
+
* `IterationContextMenu` (right-click) above run only under the toolbar's
|
|
509
|
+
* DEFAULT theme (`light`) — Storybook's `defaultTheme` isn't overridable
|
|
510
|
+
* per-story from the global decorator alone, and toggling `preview.tsx` by hand
|
|
511
|
+
* for a manual three-run sweep leaves no durable, re-runnable evidence. These
|
|
512
|
+
* four stories wrap the SAME render/play pairs in an explicit `<ThemeProvider>`
|
|
513
|
+
* instead, so `pnpm --filter @elabs-ai/components-docs test-storybook`
|
|
514
|
+
* (or `mcp__storybook__run-story-tests`) exercises `dark` and decoration 10
|
|
515
|
+
* on every run, with zero manual steps. `<ThemeProvider>` (not a plain
|
|
516
|
+
* `data-theme` wrapper div) because it writes the attribute onto the DOCUMENT
|
|
517
|
+
* ROOT — the menu's Radix `Portal` mounts its content on `document.body`,
|
|
518
|
+
* OUTSIDE any local wrapper element, so only a document-level attribute
|
|
519
|
+
* actually themes it. `storageKey={null}` keeps the override from persisting
|
|
520
|
+
* to localStorage across runs.
|
|
521
|
+
*/
|
|
522
|
+
export const NodeMenuDark: Story = {
|
|
523
|
+
name: "Iteration node menu (⋯ dropdown) — dark",
|
|
524
|
+
decorators: [
|
|
525
|
+
(Story) => (
|
|
526
|
+
<ThemeProvider defaultTheme="dark" storageKey={null}>
|
|
527
|
+
<Story />
|
|
528
|
+
</ThemeProvider>
|
|
529
|
+
),
|
|
530
|
+
],
|
|
531
|
+
render: NodeMenu.render,
|
|
532
|
+
play: NodeMenu.play,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
export const NodeMenuHighDecoration: Story = {
|
|
536
|
+
name: "Iteration node menu (⋯ dropdown) — high decoration",
|
|
537
|
+
globals: { decoration: "10" },
|
|
538
|
+
render: NodeMenu.render,
|
|
539
|
+
play: NodeMenu.play,
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export const IterationContextMenuDark: Story = {
|
|
543
|
+
name: "Iteration node menu (right-click) — dark",
|
|
544
|
+
decorators: [
|
|
545
|
+
(Story) => (
|
|
546
|
+
<ThemeProvider defaultTheme="dark" storageKey={null}>
|
|
547
|
+
<Story />
|
|
548
|
+
</ThemeProvider>
|
|
549
|
+
),
|
|
550
|
+
],
|
|
551
|
+
render: IterationContextMenu.render,
|
|
552
|
+
play: IterationContextMenu.play,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
export const IterationContextMenuHighDecoration: Story = {
|
|
556
|
+
name: "Iteration node menu (right-click) — high decoration",
|
|
557
|
+
globals: { decoration: "10" },
|
|
558
|
+
render: IterationContextMenu.render,
|
|
559
|
+
play: IterationContextMenu.play,
|
|
560
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, expect, test } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { MarkdownEditor } from "./markdown-editor";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* THE SPIKE GATE — the #1 risk of vendoring Milkdown is ProseMirror's imperative
|
|
11
|
+
* create/destroy under React 19 StrictMode (dev runs mount → cleanup → mount, so a
|
|
12
|
+
* `destroy()` can fire mid-`create()`). `@milkdown/core` defers destroy until
|
|
13
|
+
* creation settles (editor.ts), so the double-mount must CONVERGE to one editing
|
|
14
|
+
* surface — not crash, leak, or leave two editors. This proves it for our vendored
|
|
15
|
+
* integration before we build the full component on top of it.
|
|
16
|
+
*/
|
|
17
|
+
test("StrictMode double-mount converges to a single editor", async () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<StrictMode>
|
|
20
|
+
<MarkdownEditor defaultValue="# Hello Workbench" />
|
|
21
|
+
</StrictMode>,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Milkdown mounts a real ProseMirror view in jsdom and renders the heading text.
|
|
25
|
+
await waitFor(() => expect(screen.getByText("Hello Workbench")).toBeInTheDocument());
|
|
26
|
+
|
|
27
|
+
// After the deferred destroy settles, exactly one editable surface remains.
|
|
28
|
+
await waitFor(
|
|
29
|
+
() => expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(1),
|
|
30
|
+
{ timeout: 3000 },
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("renders a single editor without StrictMode too", async () => {
|
|
35
|
+
const { container } = render(<MarkdownEditor defaultValue="# Plain mount" />);
|
|
36
|
+
await waitFor(() => expect(screen.getByText("Plain mount")).toBeInTheDocument());
|
|
37
|
+
expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(1);
|
|
38
|
+
});
|