@multiplatform.one/rich-text 7.6.2 → 7.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/rich-text",
3
- "version": "7.6.2",
3
+ "version": "7.7.0",
4
4
  "description": "Cross-platform rich text editor with markdown support and syntax highlighting",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -93,25 +93,24 @@
93
93
  "marked": "^17.0.6",
94
94
  "tamagui": "2.7.6",
95
95
  "turndown": "^7.2.4",
96
- "@multiplatform.one/components": "7.6.2",
97
- "@multiplatform.one/markdown": "7.6.2",
98
- "@multiplatform.one/forms": "7.6.2",
99
- "@multiplatform.one/theme": "7.6.2"
96
+ "@multiplatform.one/components": "7.7.0",
97
+ "@multiplatform.one/forms": "7.7.0",
98
+ "@multiplatform.one/markdown": "7.7.0",
99
+ "@multiplatform.one/theme": "7.7.0"
100
100
  },
101
101
  "devDependencies": {
102
102
  "@tamagui/build": "2.7.6",
103
103
  "@testing-library/react": "^16.3.2",
104
104
  "@types/katex": "^0.16.7",
105
- "@types/react": "~19.2.14",
106
105
  "@vitejs/plugin-react": "^6.0.1",
107
106
  "@vitest/coverage-v8": "^4.1.5",
108
107
  "i18next": "^25.10.10",
109
108
  "react": "19.2.5",
110
109
  "react-dom": "19.2.5",
111
110
  "vitest": "^4.1.5",
112
- "@multiplatform.one/config": "7.6.2",
113
- "@multiplatform.one/storybook": "7.6.2",
114
- "@multiplatform.one/test-utils": "7.6.2"
111
+ "@multiplatform.one/config": "7.7.0",
112
+ "@multiplatform.one/storybook": "7.7.0",
113
+ "@multiplatform.one/test-utils": "7.7.0"
115
114
  },
116
115
  "peerDependencies": {
117
116
  "i18next": "^25.0.0",
@@ -0,0 +1,210 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const suggestion = vi.hoisted(() => ({ fn: vi.fn(() => ({ props: {} })) }));
4
+
5
+ vi.mock("@tiptap/suggestion", () => ({ default: suggestion.fn }));
6
+
7
+ const { SlashCommands } = await import("./SlashCommands");
8
+
9
+ type Item = { id: string; title: string; description: string; command: (a: any) => void };
10
+
11
+ function items(query = ""): Item[] {
12
+ return (SlashCommands as any).options.suggestion.items({ query });
13
+ }
14
+
15
+ function ids(list: Item[]) {
16
+ return list.map((i) => i.id);
17
+ }
18
+
19
+ /** Records every link in the command chain so an assertion can read the whole call. */
20
+ function makeEditor(storage: Record<string, unknown> = {}) {
21
+ const calls: Array<[string, ...unknown[]]> = [];
22
+ const chain: any = new Proxy(
23
+ {},
24
+ {
25
+ get:
26
+ (_target, prop: string) =>
27
+ (...args: unknown[]) => {
28
+ calls.push([prop, ...args]);
29
+ return chain;
30
+ },
31
+ },
32
+ );
33
+ return {
34
+ editor: { chain: () => chain, storage } as any,
35
+ calls,
36
+ names: () => calls.map((c) => c[0]),
37
+ };
38
+ }
39
+
40
+ const RANGE = { from: 3, to: 4 };
41
+
42
+ function run(id: string, editor: any) {
43
+ const item = items().find((i) => i.id === id);
44
+ if (!item) throw new Error(`no slash item "${id}"`);
45
+ item.command({ editor, range: RANGE });
46
+ }
47
+
48
+ describe("SlashCommands", () => {
49
+ // happy-dom does not implement window.prompt, so install one to observe.
50
+ let promptSpy: ReturnType<typeof vi.fn>;
51
+ let originalPrompt: typeof window.prompt;
52
+
53
+ beforeEach(() => {
54
+ suggestion.fn.mockClear();
55
+ originalPrompt = window.prompt;
56
+ promptSpy = vi.fn(() => null as string | null);
57
+ (window as any).prompt = promptSpy;
58
+ });
59
+
60
+ afterEach(() => {
61
+ (window as any).prompt = originalPrompt;
62
+ });
63
+
64
+ it("triggers on a slash anywhere in the line, not only at its start", () => {
65
+ const { suggestion: cfg } = (SlashCommands as any).options;
66
+ expect(cfg.char).toBe("/");
67
+ expect(cfg.startOfLine).toBe(false);
68
+ });
69
+
70
+ it("offers the whole block catalog on an empty query", () => {
71
+ expect(ids(items())).toEqual([
72
+ "text",
73
+ "heading1",
74
+ "heading2",
75
+ "heading3",
76
+ "bulletList",
77
+ "orderedList",
78
+ "taskList",
79
+ "codeBlock",
80
+ "blockquote",
81
+ "horizontalRule",
82
+ "table",
83
+ "image",
84
+ "mermaid",
85
+ "mathBlock",
86
+ "inlineMath",
87
+ "details",
88
+ "emoji",
89
+ ]);
90
+ });
91
+
92
+ it("matches titles without regard to case", () => {
93
+ expect(ids(items("HEAD"))).toEqual(["heading1", "heading2", "heading3"]);
94
+ expect(ids(items("head"))).toEqual(ids(items("HEAD")));
95
+ });
96
+
97
+ it("matches the description too, not the title alone", () => {
98
+ // "checkboxes" appears only in the Task List description
99
+ const byDescription = items("checkboxes");
100
+ expect(ids(byDescription)).toEqual(["taskList"]);
101
+ expect(byDescription[0].title.toLowerCase()).not.toContain("checkboxes");
102
+ });
103
+
104
+ it("returns an empty list rather than the catalog when nothing matches", () => {
105
+ expect(items("zzzznotathing")).toEqual([]);
106
+ });
107
+
108
+ it("excludeIds removes exactly the named ids from what the suggestion plugin is handed", () => {
109
+ const ext = (SlashCommands as any).configure({ excludeIds: ["table", "image", "emoji"] });
110
+ ext.config.addProseMirrorPlugins.call({ options: ext.options, editor: null });
111
+
112
+ expect(suggestion.fn).toHaveBeenCalledTimes(1);
113
+ const passed = suggestion.fn.mock.calls[0][0] as any;
114
+ const filtered = ids(passed.items({ query: "" }));
115
+
116
+ expect(filtered).not.toContain("table");
117
+ expect(filtered).not.toContain("image");
118
+ expect(filtered).not.toContain("emoji");
119
+ expect(filtered.length).toBe(items().length - 3);
120
+ expect(filtered).toEqual(
121
+ ids(items()).filter((id) => !["table", "image", "emoji"].includes(id)),
122
+ );
123
+ });
124
+
125
+ it("an empty excludeIds leaves the catalog whole", () => {
126
+ const ext = (SlashCommands as any).configure({});
127
+ ext.config.addProseMirrorPlugins.call({ options: ext.options, editor: null });
128
+ const passed = suggestion.fn.mock.calls[0][0] as any;
129
+ expect(ids(passed.items({ query: "" }))).toEqual(ids(items()));
130
+ });
131
+
132
+ it("every command deletes the typed trigger range before it inserts anything", () => {
133
+ for (const id of ["text", "heading1", "bulletList", "codeBlock", "table", "details"]) {
134
+ const { editor, calls } = makeEditor();
135
+ run(id, editor);
136
+ const deleteCall = calls.find(([name]) => name === "deleteRange");
137
+ expect(deleteCall, `${id} left the "/" behind`).toEqual(["deleteRange", RANGE]);
138
+ }
139
+ });
140
+
141
+ it("headings carry their level, and Text resets to a paragraph", () => {
142
+ const h2 = makeEditor();
143
+ run("heading2", h2.editor);
144
+ expect(h2.calls).toContainEqual(["setHeading", { level: 2 }]);
145
+
146
+ const p = makeEditor();
147
+ run("text", p.editor);
148
+ expect(p.names()).toEqual(["focus", "deleteRange", "setParagraph", "run"]);
149
+ });
150
+
151
+ it("Table inserts a 3x3 grid with a header row", () => {
152
+ const { editor, calls } = makeEditor();
153
+ run("table", editor);
154
+ expect(calls).toContainEqual(["insertTable", { rows: 3, cols: 3, withHeaderRow: true }]);
155
+ });
156
+
157
+ it("Mermaid and Math Block are code blocks with a language, not bespoke nodes", () => {
158
+ const mermaid = makeEditor();
159
+ run("mermaid", mermaid.editor);
160
+ expect(mermaid.calls).toContainEqual(["setCodeBlock", { language: "mermaid" }]);
161
+
162
+ const math = makeEditor();
163
+ run("mathBlock", math.editor);
164
+ expect(math.calls).toContainEqual(["setCodeBlock", { language: "math" }]);
165
+ });
166
+
167
+ it("a collapsible section is inserted already open, so it can be typed into", () => {
168
+ const { editor, calls, names } = makeEditor();
169
+ run("details", editor);
170
+ expect(names()).toEqual(["focus", "deleteRange", "setDetails", "updateAttributes", "run"]);
171
+ expect(calls).toContainEqual(["updateAttributes", "details", { open: true }]);
172
+ });
173
+
174
+ it("Emoji hands off to the emoji popup by typing its trigger", () => {
175
+ const { editor, calls } = makeEditor();
176
+ run("emoji", editor);
177
+ expect(calls).toContainEqual(["insertContent", ":"]);
178
+ });
179
+
180
+ it("Image prompts for a URL only when no upload handler is wired", () => {
181
+ promptSpy.mockReturnValue("https://example.com/a.png");
182
+ const { editor, calls } = makeEditor();
183
+
184
+ run("image", editor);
185
+
186
+ expect(promptSpy).toHaveBeenCalled();
187
+ expect(calls).toContainEqual(["deleteRange", RANGE]);
188
+ expect(calls).toContainEqual(["setImage", { src: "https://example.com/a.png" }]);
189
+ });
190
+
191
+ it("a dismissed URL prompt inserts nothing", () => {
192
+ promptSpy.mockReturnValue(null);
193
+ const { editor, calls } = makeEditor();
194
+
195
+ run("image", editor);
196
+
197
+ expect(calls).toEqual([]);
198
+ });
199
+
200
+ it("Image opens the file picker instead of prompting when an upload handler exists", () => {
201
+ const onImageUpload = vi.fn(async () => "https://cdn.example.com/x.png");
202
+ const { editor, calls } = makeEditor({ mediaUpload: { handlers: { onImageUpload } } });
203
+
204
+ run("image", editor);
205
+
206
+ expect(promptSpy).not.toHaveBeenCalled();
207
+ // the picker is opened and nothing is inserted until a file comes back
208
+ expect(calls).toEqual([]);
209
+ });
210
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { act, fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { RichEditorContext } from "../context";
5
+
6
+ const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
7
+
8
+ vi.mock("@tiptap/react/menus", () => ({
9
+ BubbleMenu: (props: any) => {
10
+ bubble.props = props;
11
+ return <div data-testid="bubble-menu">{props.children}</div>;
12
+ },
13
+ }));
14
+
15
+ // The language list lives inside a Popover, which never mounts its content in
16
+ // a headless DOM. Flatten the popover so the list is inspectable.
17
+ vi.mock("tamagui", async (importOriginal) => {
18
+ const actual = await importOriginal<Record<string, any>>();
19
+ const Popover: any = ({ children }: any) => <>{children}</>;
20
+ Popover.Trigger = ({ children }: any) => <>{children}</>;
21
+ return { ...actual, Popover };
22
+ });
23
+
24
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
25
+ ...(await importOriginal<Record<string, unknown>>()),
26
+ Tooltip: ({ children }: any) => children,
27
+ PopoverContent: ({ children }: any) => <div data-testid="language-menu">{children}</div>,
28
+ }));
29
+
30
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
31
+ ...(await importOriginal<Record<string, unknown>>()),
32
+ Button: (props: any) => (
33
+ <button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
34
+ {props.children}
35
+ </button>
36
+ ),
37
+ }));
38
+
39
+ const { CodeBlockBubble } = await import("./CodeBlockBubble");
40
+
41
+ function makeEditor(
42
+ opts: { language?: string | null; code?: string; active?: boolean; attrs?: unknown } = {},
43
+ ) {
44
+ const { language = null, code = "const a = 1;", active = true } = opts;
45
+ const calls: Array<[string, ...unknown[]]> = [];
46
+ const chain: any = new Proxy(
47
+ {},
48
+ {
49
+ get:
50
+ (_target, prop: string) =>
51
+ (...args: unknown[]) => {
52
+ calls.push([prop, ...args]);
53
+ return chain;
54
+ },
55
+ },
56
+ );
57
+ const codeBlockNode = { type: { name: "codeBlock" }, textContent: code };
58
+ const docNode = { type: { name: "doc" }, textContent: "outer" };
59
+ const editor = {
60
+ chain: () => chain,
61
+ getAttributes: (name: string) =>
62
+ "attrs" in opts ? (opts.attrs as any) : name === "codeBlock" ? { language } : {},
63
+ isActive: (name: string) => name === "codeBlock" && active,
64
+ state: {
65
+ selection: { $from: { depth: 1, node: (d: number) => (d === 1 ? codeBlockNode : docNode) } },
66
+ },
67
+ } as any;
68
+ return { editor, calls, names: () => calls.map((c) => c[0]) };
69
+ }
70
+
71
+ function render(editor: any) {
72
+ return renderWithProviders(
73
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
74
+ <CodeBlockBubble editor={editor} />
75
+ </RichEditorContext.Provider>,
76
+ );
77
+ }
78
+
79
+ function labelOf(container: HTMLElement, selector: string) {
80
+ return container.querySelector(selector)?.getAttribute("aria-label");
81
+ }
82
+
83
+ function menuLabels(container: HTMLElement) {
84
+ const menu = container.querySelector('[data-testid="language-menu"]');
85
+ if (!menu) throw new Error("language menu did not render");
86
+ return Array.from(menu.querySelectorAll("[aria-label]")).map((el) =>
87
+ el.getAttribute("aria-label"),
88
+ );
89
+ }
90
+
91
+ describe("CodeBlockBubble", () => {
92
+ let writeText: ReturnType<typeof vi.fn>;
93
+
94
+ beforeEach(() => {
95
+ writeText = vi.fn(() => Promise.resolve());
96
+ Object.defineProperty(globalThis.navigator, "clipboard", {
97
+ value: { writeText },
98
+ configurable: true,
99
+ writable: true,
100
+ });
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.useRealTimers();
105
+ });
106
+
107
+ it("renders nothing at all without an editor", () => {
108
+ const { container } = render(null);
109
+ expect(container.textContent).toBe("");
110
+ });
111
+
112
+ it("shows itself only while the caret is inside a code block", () => {
113
+ const inside = makeEditor({ active: true });
114
+ render(inside.editor);
115
+ expect(bubble.props?.shouldShow({ editor: inside.editor })).toBe(true);
116
+
117
+ const outside = makeEditor({ active: false });
118
+ render(outside.editor);
119
+ expect(bubble.props?.shouldShow({ editor: outside.editor })).toBe(false);
120
+ });
121
+
122
+ it("anchors to the block rect, not the caret, so it does not chase typing", () => {
123
+ render(makeEditor().editor);
124
+ expect(typeof bubble.props?.getReferencedVirtualElement).toBe("function");
125
+ expect(bubble.props?.options).toMatchObject({ placement: "top-start" });
126
+ });
127
+
128
+ it("labels a null language as Plain text", () => {
129
+ const { container } = render(makeEditor({ language: null }).editor);
130
+ expect(container.querySelector('[aria-label="Select language"]')?.textContent).toBe(
131
+ "Plain text",
132
+ );
133
+ });
134
+
135
+ it("labels a known language with its catalog label, not its attribute value", () => {
136
+ const { container } = render(makeEditor({ language: "typescript" }).editor);
137
+ const trigger = container.querySelector('[aria-label="Select language"]');
138
+ expect(trigger?.textContent).toBe("TypeScript");
139
+ expect(trigger?.textContent).not.toBe("typescript");
140
+ });
141
+
142
+ it("falls back to the raw attribute for a language the catalog does not know", () => {
143
+ const { container } = render(makeEditor({ language: "brainfuck" }).editor);
144
+ const trigger = container.querySelector('[aria-label="Select language"]');
145
+ expect(trigger?.textContent).toBe("brainfuck");
146
+ });
147
+
148
+ it("falls back to Plain text when the node carries no language attribute at all", () => {
149
+ const { container } = render(makeEditor({ attrs: undefined }).editor);
150
+ const trigger = container.querySelector('[aria-label="Select language"]');
151
+ expect(trigger?.textContent).toBe("Plain text");
152
+ });
153
+
154
+ it("offers a language list headed by Plain text", () => {
155
+ const { container } = render(makeEditor().editor);
156
+ const labels = menuLabels(container);
157
+ expect(labels[0]).toBe("Plain text");
158
+ for (const label of ["TypeScript", "Python", "SQL", "Mermaid", "Math (TeX)"]) {
159
+ expect(labels).toContain(label);
160
+ }
161
+ });
162
+
163
+ it("picking a language writes it onto the node; picking Plain text clears it to null", () => {
164
+ const picked = makeEditor({ language: null });
165
+ const { container } = render(picked.editor);
166
+ const menu = container.querySelector('[data-testid="language-menu"]') as HTMLElement;
167
+
168
+ fireEvent.click(menu.querySelector('[aria-label="Python"]')!);
169
+ expect(picked.names()).toEqual(["focus", "updateAttributes", "run"]);
170
+ expect(picked.calls[1]).toEqual(["updateAttributes", "codeBlock", { language: "python" }]);
171
+
172
+ const cleared = makeEditor({ language: "python" });
173
+ const { container: c2 } = render(cleared.editor);
174
+ const menu2 = c2.querySelector('[data-testid="language-menu"]') as HTMLElement;
175
+ fireEvent.click(menu2.querySelector('[aria-label="Plain text"]')!);
176
+ expect(cleared.calls[1]).toEqual(["updateAttributes", "codeBlock", { language: null }]);
177
+ });
178
+
179
+ it("copies the text of the code block the caret is in, and says so for 1.5s", async () => {
180
+ vi.useFakeTimers();
181
+ const { container } = render(makeEditor({ code: "print('hi')" }).editor);
182
+
183
+ await act(async () => {
184
+ fireEvent.click(container.querySelector('[aria-label="Copy code"]')!);
185
+ });
186
+
187
+ expect(writeText).toHaveBeenCalledWith("print('hi')");
188
+ expect(labelOf(container, '[aria-label="Copied"]')).toBe("Copied");
189
+
190
+ act(() => {
191
+ vi.advanceTimersByTime(1500);
192
+ });
193
+ expect(container.querySelector('[aria-label="Copied"]')).toBeNull();
194
+ expect(container.querySelector('[aria-label="Copy code"]')).toBeTruthy();
195
+ });
196
+
197
+ it("a rejected clipboard write warns and leaves the label alone", async () => {
198
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
199
+ writeText.mockRejectedValueOnce(new Error("denied"));
200
+ const { container } = render(makeEditor().editor);
201
+
202
+ await act(async () => {
203
+ fireEvent.click(container.querySelector('[aria-label="Copy code"]')!);
204
+ });
205
+
206
+ expect(warn).toHaveBeenCalled();
207
+ expect(container.querySelector('[aria-label="Copied"]')).toBeNull();
208
+ warn.mockRestore();
209
+ });
210
+
211
+ it("remove toggles the code block off rather than deleting the selection", () => {
212
+ const { editor, names, calls } = makeEditor();
213
+ const { container } = render(editor);
214
+
215
+ fireEvent.click(container.querySelector('[aria-label="Remove code block"]')!);
216
+ expect(names()).toEqual(["focus", "toggleCodeBlock", "run"]);
217
+ expect(calls.some(([n]) => n === "deleteSelection")).toBe(false);
218
+ });
219
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { RichEditorContext } from "../context";
5
+
6
+ const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
7
+
8
+ vi.mock("@tiptap/react/menus", () => ({
9
+ BubbleMenu: (props: any) => {
10
+ bubble.props = props;
11
+ return <div data-testid="bubble-menu">{props.children}</div>;
12
+ },
13
+ }));
14
+
15
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
16
+ ...(await importOriginal<Record<string, unknown>>()),
17
+ Button: (props: any) => (
18
+ <button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
19
+ {props.children}
20
+ </button>
21
+ ),
22
+ }));
23
+
24
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
25
+ ...(await importOriginal<Record<string, unknown>>()),
26
+ Tooltip: ({ children }: any) => children,
27
+ }));
28
+
29
+ const { ImageBubble } = await import("./ImageBubble");
30
+
31
+ /** Records every link in the command chain so an assertion can read the whole call. */
32
+ function makeEditor(attrs: Record<string, unknown> = {}, active = true) {
33
+ const calls: Array<[string, ...unknown[]]> = [];
34
+ const chain: any = new Proxy(
35
+ {},
36
+ {
37
+ get:
38
+ (_target, prop: string) =>
39
+ (...args: unknown[]) => {
40
+ calls.push([prop, ...args]);
41
+ return chain;
42
+ },
43
+ },
44
+ );
45
+ const editor = {
46
+ chain: () => chain,
47
+ getAttributes: (name: string) => (name === "image" ? attrs : {}),
48
+ isActive: (name: string) => name === "image" && active,
49
+ } as any;
50
+ return { editor, calls, names: () => calls.map((c) => c[0]) };
51
+ }
52
+
53
+ function render(editor: any) {
54
+ return renderWithProviders(
55
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
56
+ <ImageBubble editor={editor} />
57
+ </RichEditorContext.Provider>,
58
+ );
59
+ }
60
+
61
+ function press(container: HTMLElement, label: string) {
62
+ const el = container.querySelector(`[aria-label="${label}"]`);
63
+ if (!el) throw new Error(`no control labelled "${label}"`);
64
+ fireEvent.click(el);
65
+ return el;
66
+ }
67
+
68
+ describe("ImageBubble", () => {
69
+ it("renders nothing at all without an editor", () => {
70
+ const { container } = render(null);
71
+ expect(container.textContent).toBe("");
72
+ expect(container.querySelector('[data-testid="bubble-menu"]')).toBeNull();
73
+ });
74
+
75
+ it("shows itself only while an image node is selected", () => {
76
+ const onImage = makeEditor({ src: "a.png" }, true);
77
+ render(onImage.editor);
78
+ expect(bubble.props?.shouldShow({ editor: onImage.editor })).toBe(true);
79
+
80
+ const offImage = makeEditor({}, false);
81
+ render(offImage.editor);
82
+ expect(bubble.props?.shouldShow({ editor: offImage.editor })).toBe(false);
83
+ });
84
+
85
+ it("shows the image's alt text, and says so plainly when there is none", () => {
86
+ const withAlt = render(makeEditor({ alt: "A red bicycle" }).editor);
87
+ expect(withAlt.getByText("A red bicycle")).toBeTruthy();
88
+
89
+ const withoutAlt = render(makeEditor({ src: "a.png" }).editor);
90
+ expect(withoutAlt.getByText("No alt text")).toBeTruthy();
91
+ });
92
+
93
+ it("prefills the editing field with the alt text already on the node", () => {
94
+ const { container, getByText } = render(makeEditor({ alt: "A red bicycle" }).editor);
95
+ press(container, "Edit alt text");
96
+
97
+ expect(getByText("Alt text")).toBeTruthy();
98
+ const input = container.querySelector("input") as HTMLInputElement;
99
+ expect(input.value).toBe("A red bicycle");
100
+ });
101
+
102
+ it("writes the edited alt text back onto the image node and leaves editing", () => {
103
+ const { editor, calls, names } = makeEditor({ alt: "old" });
104
+ const { container, queryByText } = render(editor);
105
+
106
+ press(container, "Edit alt text");
107
+ fireEvent.change(container.querySelector("input") as HTMLInputElement, {
108
+ target: { value: "new alt" },
109
+ });
110
+ press(container, "Save alt text");
111
+
112
+ expect(names()).toEqual(["focus", "updateAttributes", "run"]);
113
+ expect(calls[1]).toEqual(["updateAttributes", "image", { alt: "new alt" }]);
114
+ expect(queryByText("Alt text")).toBeNull();
115
+ });
116
+
117
+ it("cancel leaves editing without touching the document", () => {
118
+ const { editor, calls } = makeEditor({ alt: "old" });
119
+ const { container, queryByText, getByText } = render(editor);
120
+
121
+ press(container, "Edit alt text");
122
+ fireEvent.change(container.querySelector("input") as HTMLInputElement, {
123
+ target: { value: "discarded" },
124
+ });
125
+ press(container, "Cancel");
126
+
127
+ expect(calls).toEqual([]);
128
+ expect(queryByText("Alt text")).toBeNull();
129
+ expect(getByText("old")).toBeTruthy();
130
+ });
131
+
132
+ it("delete removes the selected node rather than clearing its attributes", () => {
133
+ const { editor, calls, names } = makeEditor({ alt: "old" });
134
+ const { container } = render(editor);
135
+
136
+ press(container, "Delete image");
137
+ expect(names()).toEqual(["focus", "deleteSelection", "run"]);
138
+ expect(calls.some(([name]) => name === "updateAttributes")).toBe(false);
139
+ });
140
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { RichEditorContext } from "../context";
5
+
6
+ const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
7
+
8
+ vi.mock("@tiptap/react/menus", () => ({
9
+ BubbleMenu: (props: any) => {
10
+ bubble.props = props;
11
+ return <div data-testid="bubble-menu">{props.children}</div>;
12
+ },
13
+ }));
14
+
15
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
16
+ ...(await importOriginal<Record<string, unknown>>()),
17
+ Button: (props: any) => (
18
+ <button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
19
+ {props.children}
20
+ </button>
21
+ ),
22
+ }));
23
+
24
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
25
+ ...(await importOriginal<Record<string, unknown>>()),
26
+ Tooltip: ({ children }: any) => children,
27
+ }));
28
+
29
+ const { LinkBubble } = await import("./LinkBubble");
30
+
31
+ function makeEditor(attrs: Record<string, unknown> = {}, active = true) {
32
+ const calls: Array<[string, ...unknown[]]> = [];
33
+ const chain: any = new Proxy(
34
+ {},
35
+ {
36
+ get:
37
+ (_target, prop: string) =>
38
+ (...args: unknown[]) => {
39
+ calls.push([prop, ...args]);
40
+ return chain;
41
+ },
42
+ },
43
+ );
44
+ const editor = {
45
+ chain: () => chain,
46
+ getAttributes: (name: string) => (name === "link" ? attrs : {}),
47
+ isActive: (name: string) => name === "link" && active,
48
+ } as any;
49
+ return { editor, calls, names: () => calls.map((c) => c[0]) };
50
+ }
51
+
52
+ function render(editor: any) {
53
+ return renderWithProviders(
54
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
55
+ <LinkBubble editor={editor} />
56
+ </RichEditorContext.Provider>,
57
+ );
58
+ }
59
+
60
+ function press(container: HTMLElement, label: string) {
61
+ const el = container.querySelector(`[aria-label="${label}"]`);
62
+ if (!el) throw new Error(`no control labelled "${label}"`);
63
+ fireEvent.click(el);
64
+ return el;
65
+ }
66
+
67
+ describe("LinkBubble", () => {
68
+ beforeEach(() => {
69
+ vi.restoreAllMocks();
70
+ });
71
+
72
+ it("renders nothing at all without an editor", () => {
73
+ const { container } = render(null);
74
+ expect(container.textContent).toBe("");
75
+ });
76
+
77
+ it("shows itself only while the caret is on a link", () => {
78
+ const onLink = makeEditor({ href: "https://example.com" }, true);
79
+ render(onLink.editor);
80
+ expect(bubble.props?.shouldShow({ editor: onLink.editor })).toBe(true);
81
+
82
+ const offLink = makeEditor({}, false);
83
+ render(offLink.editor);
84
+ expect(bubble.props?.shouldShow({ editor: offLink.editor })).toBe(false);
85
+ });
86
+
87
+ it("shows the href, and says so plainly when the mark carries none", () => {
88
+ const withHref = render(makeEditor({ href: "https://example.com/a" }).editor);
89
+ expect(withHref.getByText("https://example.com/a")).toBeTruthy();
90
+
91
+ const withoutHref = render(makeEditor({}).editor);
92
+ expect(withoutHref.getByText("No URL")).toBeTruthy();
93
+ });
94
+
95
+ it("opens the href in a new tab with noopener, from both the label and the button", () => {
96
+ const open = vi.spyOn(window, "open").mockImplementation(() => null);
97
+ const { container, getByText } = render(makeEditor({ href: "https://example.com" }).editor);
98
+
99
+ fireEvent.click(getByText("https://example.com"));
100
+ press(container, "Open link");
101
+
102
+ expect(open).toHaveBeenCalledTimes(2);
103
+ expect(open).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer");
104
+ });
105
+
106
+ it("does not open a window when there is no href to open", () => {
107
+ const open = vi.spyOn(window, "open").mockImplementation(() => null);
108
+ const { container } = render(makeEditor({}).editor);
109
+
110
+ press(container, "Open link");
111
+ expect(open).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("prefills the edit field with the current href", () => {
115
+ const { container } = render(makeEditor({ href: "https://example.com" }).editor);
116
+ press(container, "Edit link");
117
+ expect((container.querySelector("input") as HTMLInputElement).value).toBe(
118
+ "https://example.com",
119
+ );
120
+ });
121
+
122
+ it("saves over the whole mark range, with the value trimmed", () => {
123
+ const { editor, calls, names } = makeEditor({ href: "https://old.example" });
124
+ const { container } = render(editor);
125
+
126
+ press(container, "Edit link");
127
+ fireEvent.change(container.querySelector("input") as HTMLInputElement, {
128
+ target: { value: " https://new.example " },
129
+ });
130
+ press(container, "Save link");
131
+
132
+ expect(names()).toEqual(["focus", "extendMarkRange", "setLink", "run"]);
133
+ expect(calls[1]).toEqual(["extendMarkRange", "link"]);
134
+ expect(calls[2]).toEqual(["setLink", { href: "https://new.example" }]);
135
+ });
136
+
137
+ it("refuses to save a blank href, and still closes the editor", () => {
138
+ const { editor, calls } = makeEditor({ href: "https://old.example" });
139
+ const { container, getByText } = render(editor);
140
+
141
+ press(container, "Edit link");
142
+ fireEvent.change(container.querySelector("input") as HTMLInputElement, {
143
+ target: { value: " " },
144
+ });
145
+ press(container, "Save link");
146
+
147
+ expect(calls).toEqual([]);
148
+ expect(getByText("https://old.example")).toBeTruthy();
149
+ });
150
+
151
+ it("unlinks over the whole mark range rather than the selection alone", () => {
152
+ const { editor, calls, names } = makeEditor({ href: "https://example.com" });
153
+ const { container } = render(editor);
154
+
155
+ press(container, "Remove link");
156
+ expect(names()).toEqual(["focus", "extendMarkRange", "unsetLink", "run"]);
157
+ expect(calls[1]).toEqual(["extendMarkRange", "link"]);
158
+ });
159
+ });
@@ -0,0 +1,118 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it, beforeEach } from "vitest";
3
+ import { fireEvent } from "@testing-library/react";
4
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
5
+ import { ResizeHandle } from "./ResizeHandle";
6
+
7
+ /** The handle drives a real element, so give it one with a measurable height. */
8
+ function makeContainer(height: number) {
9
+ const el = document.createElement("div");
10
+ el.getBoundingClientRect = () => ({ height }) as DOMRect;
11
+ document.body.appendChild(el);
12
+ const ref = createRef<HTMLElement>();
13
+ (ref as { current: HTMLElement | null }).current = el;
14
+ return { el, ref };
15
+ }
16
+
17
+ function grab(container: HTMLElement) {
18
+ const handle = container.querySelector('[role="separator"]');
19
+ if (!handle) throw new Error("resize handle did not render");
20
+ return handle;
21
+ }
22
+
23
+ function drag(handle: Element, fromY: number, toY: number) {
24
+ fireEvent.mouseDown(handle, { clientY: fromY });
25
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: toY }));
26
+ }
27
+
28
+ describe("ResizeHandle", () => {
29
+ beforeEach(() => {
30
+ document.body.innerHTML = "";
31
+ document.body.style.cursor = "";
32
+ document.body.style.userSelect = "";
33
+ });
34
+
35
+ it("exposes itself to assistive tech as a labelled separator", () => {
36
+ const { ref } = makeContainer(300);
37
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
38
+ const handle = grab(container);
39
+ expect(handle.getAttribute("aria-label")).toBe("Resize editor");
40
+ });
41
+
42
+ it("adds the drag delta to the height the container had when the drag started", () => {
43
+ const { el, ref } = makeContainer(300);
44
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
45
+
46
+ drag(grab(container), 100, 180);
47
+ expect(el.style.height).toBe("380px");
48
+ });
49
+
50
+ it("shrinks on an upward drag, and floors at minHeight (default 150)", () => {
51
+ const { el, ref } = makeContainer(300);
52
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
53
+ const handle = grab(container);
54
+
55
+ drag(handle, 100, 60);
56
+ expect(el.style.height).toBe("260px");
57
+
58
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: -500 }));
59
+ expect(el.style.height).toBe("150px");
60
+ });
61
+
62
+ it("honours an explicit minHeight over the default", () => {
63
+ const { el, ref } = makeContainer(300);
64
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} minHeight={220} />);
65
+
66
+ drag(grab(container), 100, -900);
67
+ expect(el.style.height).toBe("220px");
68
+ });
69
+
70
+ it("caps at maxHeight when one is given, and does not cap when it is omitted", () => {
71
+ const capped = makeContainer(300);
72
+ const { container: cappedDom } = renderWithProviders(
73
+ <ResizeHandle containerRef={capped.ref} maxHeight={400} />,
74
+ );
75
+ drag(grab(cappedDom), 100, 900);
76
+ expect(capped.el.style.height).toBe("400px");
77
+
78
+ document.dispatchEvent(new MouseEvent("mouseup"));
79
+
80
+ const free = makeContainer(300);
81
+ const { container: freeDom } = renderWithProviders(<ResizeHandle containerRef={free.ref} />);
82
+ drag(grab(freeDom), 100, 900);
83
+ expect(free.el.style.height).toBe("1100px");
84
+ });
85
+
86
+ it("locks the page cursor and selection for the length of the drag only", () => {
87
+ const { ref } = makeContainer(300);
88
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
89
+
90
+ fireEvent.mouseDown(grab(container), { clientY: 100 });
91
+ expect(document.body.style.cursor).toBe("ns-resize");
92
+ expect(document.body.style.userSelect).toBe("none");
93
+
94
+ document.dispatchEvent(new MouseEvent("mouseup"));
95
+ expect(document.body.style.cursor).toBe("");
96
+ expect(document.body.style.userSelect).toBe("");
97
+ });
98
+
99
+ it("stops resizing after mouseup, so a later pointer move is inert", () => {
100
+ const { el, ref } = makeContainer(300);
101
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
102
+
103
+ drag(grab(container), 100, 200);
104
+ expect(el.style.height).toBe("400px");
105
+
106
+ document.dispatchEvent(new MouseEvent("mouseup"));
107
+ document.dispatchEvent(new MouseEvent("mousemove", { clientY: 900 }));
108
+ expect(el.style.height).toBe("400px");
109
+ });
110
+
111
+ it("is a no-op while the container ref is still empty", () => {
112
+ const ref = createRef<HTMLElement>();
113
+ const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
114
+
115
+ fireEvent.mouseDown(grab(container), { clientY: 100 });
116
+ expect(document.body.style.cursor).toBe("");
117
+ });
118
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { RichEditorContext } from "../context";
5
+ import { defaultEditorState } from "../types";
6
+ import type { EditorState } from "../types";
7
+ import { StatusBar } from "./StatusBar";
8
+
9
+ function withContext(ui: React.ReactElement) {
10
+ return (
11
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
12
+ {ui}
13
+ </RichEditorContext.Provider>
14
+ );
15
+ }
16
+
17
+ function stateWith(counts: Partial<EditorState>): EditorState {
18
+ return { ...defaultEditorState, ...counts };
19
+ }
20
+
21
+ describe("StatusBar", () => {
22
+ it("offers both editor modes as pressable labels", () => {
23
+ const { getByText } = renderWithProviders(
24
+ withContext(<StatusBar state={defaultEditorState} mode="richtext" onModeChange={() => {}} />),
25
+ );
26
+ expect(getByText("Rich text")).toBeTruthy();
27
+ expect(getByText("Markdown")).toBeTruthy();
28
+ });
29
+
30
+ it("reports the mode the user picked, not the mode it is already in", () => {
31
+ const onModeChange = vi.fn();
32
+ const { getByText } = renderWithProviders(
33
+ withContext(
34
+ <StatusBar state={defaultEditorState} mode="richtext" onModeChange={onModeChange} />,
35
+ ),
36
+ );
37
+
38
+ fireEvent.click(getByText("Markdown"));
39
+ expect(onModeChange).toHaveBeenCalledWith("markdown");
40
+
41
+ fireEvent.click(getByText("Rich text"));
42
+ expect(onModeChange).toHaveBeenLastCalledWith("richtext");
43
+ expect(onModeChange).toHaveBeenCalledTimes(2);
44
+ });
45
+
46
+ it("marks the active mode, and swaps the marking when the mode changes", () => {
47
+ const { getByText, rerender } = renderWithProviders(
48
+ withContext(<StatusBar state={defaultEditorState} mode="richtext" onModeChange={() => {}} />),
49
+ );
50
+ // Tamagui compiles the weight/colour props to class tokens, so the
51
+ // rendered class list is where "active" is actually observable.
52
+ const classesOf = (label: string) => (getByText(label) as HTMLElement).className;
53
+
54
+ const activeClasses = classesOf("Rich text");
55
+ const idleClasses = classesOf("Markdown");
56
+ expect(activeClasses).not.toBe(idleClasses);
57
+ expect(activeClasses).toContain("_fow-600");
58
+ expect(idleClasses).toContain("_fow-400");
59
+
60
+ rerender(
61
+ withContext(<StatusBar state={defaultEditorState} mode="markdown" onModeChange={() => {}} />),
62
+ );
63
+ expect(classesOf("Markdown")).toBe(activeClasses);
64
+ expect(classesOf("Rich text")).toBe(idleClasses);
65
+ });
66
+
67
+ it("singularises exactly at one word and one character", () => {
68
+ const { getByText } = renderWithProviders(
69
+ withContext(
70
+ <StatusBar
71
+ state={stateWith({ wordCount: 1, characterCount: 1 })}
72
+ mode="richtext"
73
+ onModeChange={() => {}}
74
+ />,
75
+ ),
76
+ );
77
+ expect(getByText("1 word")).toBeTruthy();
78
+ expect(getByText("1 character")).toBeTruthy();
79
+ });
80
+
81
+ it("pluralises at zero as well as at many", () => {
82
+ const { getByText, rerender } = renderWithProviders(
83
+ withContext(<StatusBar state={defaultEditorState} mode="richtext" onModeChange={() => {}} />),
84
+ );
85
+ expect(getByText("0 words")).toBeTruthy();
86
+ expect(getByText("0 characters")).toBeTruthy();
87
+
88
+ rerender(
89
+ withContext(
90
+ <StatusBar
91
+ state={stateWith({ wordCount: 42, characterCount: 240 })}
92
+ mode="richtext"
93
+ onModeChange={() => {}}
94
+ />,
95
+ ),
96
+ );
97
+ expect(getByText("42 words")).toBeTruthy();
98
+ expect(getByText("240 characters")).toBeTruthy();
99
+ });
100
+ });
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { RichEditorContext } from "../context";
5
+ import type { EditorCapabilities } from "../types";
6
+
7
+ const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
8
+
9
+ vi.mock("@tiptap/react/menus", () => ({
10
+ BubbleMenu: (props: any) => {
11
+ bubble.props = props;
12
+ return <div data-testid="bubble-menu">{props.children}</div>;
13
+ },
14
+ }));
15
+
16
+ // The table menu lives inside a Popover, which never mounts its content in a
17
+ // headless DOM. Flatten the popover so the menu is inspectable; nothing else
18
+ // about tamagui is replaced.
19
+ vi.mock("tamagui", async (importOriginal) => {
20
+ const actual = await importOriginal<Record<string, any>>();
21
+ const Popover: any = ({ children }: any) => <>{children}</>;
22
+ Popover.Trigger = ({ children }: any) => <>{children}</>;
23
+ return { ...actual, Popover };
24
+ });
25
+
26
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
27
+ ...(await importOriginal<Record<string, unknown>>()),
28
+ Tooltip: ({ children }: any) => children,
29
+ PopoverContent: ({ children }: any) => <div data-testid="table-menu">{children}</div>,
30
+ }));
31
+
32
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
33
+ ...(await importOriginal<Record<string, unknown>>()),
34
+ Button: (props: any) => (
35
+ <button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
36
+ {props.children}
37
+ </button>
38
+ ),
39
+ }));
40
+
41
+ const { TableBubble } = await import("./TableBubble");
42
+
43
+ function makeEditor(active = true) {
44
+ const calls: Array<[string, ...unknown[]]> = [];
45
+ const chain: any = new Proxy(
46
+ {},
47
+ {
48
+ get:
49
+ (_target, prop: string) =>
50
+ (...args: unknown[]) => {
51
+ calls.push([prop, ...args]);
52
+ return chain;
53
+ },
54
+ },
55
+ );
56
+ const editor = {
57
+ chain: () => chain,
58
+ getAttributes: () => ({}),
59
+ isActive: (name: string) => name === "table" && active,
60
+ } as any;
61
+ return { editor, calls, names: () => calls.map((c) => c[0]) };
62
+ }
63
+
64
+ function render(editor: any, capabilities?: EditorCapabilities) {
65
+ return renderWithProviders(
66
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
67
+ <TableBubble editor={editor} capabilities={capabilities} />
68
+ </RichEditorContext.Provider>,
69
+ );
70
+ }
71
+
72
+ /** Labels of the items inside the Table dropdown, in document order. */
73
+ function menuLabels(container: HTMLElement) {
74
+ const menu = container.querySelector('[data-testid="table-menu"]');
75
+ if (!menu) throw new Error("table menu did not render");
76
+ return Array.from(menu.querySelectorAll("[aria-label]")).map((el) =>
77
+ el.getAttribute("aria-label"),
78
+ );
79
+ }
80
+
81
+ function pressInMenu(container: HTMLElement, label: string) {
82
+ const menu = container.querySelector('[data-testid="table-menu"]') as HTMLElement;
83
+ const el = menu.querySelector(`[aria-label="${label}"]`);
84
+ if (!el) throw new Error(`no menu item labelled "${label}"`);
85
+ fireEvent.click(el);
86
+ }
87
+
88
+ const ROW_AND_COLUMN = [
89
+ "Add row above",
90
+ "Add row below",
91
+ "Delete row",
92
+ "Add column before",
93
+ "Add column after",
94
+ "Delete column",
95
+ ];
96
+
97
+ describe("TableBubble", () => {
98
+ it("renders nothing at all without an editor", () => {
99
+ const { container } = render(null);
100
+ expect(container.textContent).toBe("");
101
+ });
102
+
103
+ it("shows itself only while the caret is inside a table", () => {
104
+ const inTable = makeEditor(true);
105
+ render(inTable.editor);
106
+ expect(bubble.props?.shouldShow({ editor: inTable.editor })).toBe(true);
107
+
108
+ const outside = makeEditor(false);
109
+ render(outside.editor);
110
+ expect(bubble.props?.shouldShow({ editor: outside.editor })).toBe(false);
111
+ });
112
+
113
+ it("anchors to the table rect, not the caret, so it cannot cover the first row", () => {
114
+ const { editor } = makeEditor();
115
+ render(editor);
116
+ expect(typeof bubble.props?.getReferencedVirtualElement).toBe("function");
117
+ expect(bubble.props?.options).toMatchObject({ placement: "top-start" });
118
+ });
119
+
120
+ // LC-52: GFM pipe tables are plain grids, so merge and split have no
121
+ // markdown form. The markdown capability must drop exactly those two.
122
+ it("markdown capability removes exactly mergeCells and splitCell", () => {
123
+ const { container } = render(makeEditor().editor, "markdown");
124
+ const labels = menuLabels(container);
125
+
126
+ expect(labels).not.toContain("Merge cells");
127
+ expect(labels).not.toContain("Split cell");
128
+ expect(labels).toContain("Toggle header row");
129
+ expect(labels).toContain("Delete table");
130
+ });
131
+
132
+ it("full capability keeps all four table actions", () => {
133
+ const { container } = render(makeEditor().editor, "full");
134
+ const labels = menuLabels(container);
135
+
136
+ expect(labels).toContain("Toggle header row");
137
+ expect(labels).toContain("Merge cells");
138
+ expect(labels).toContain("Split cell");
139
+ expect(labels).toContain("Delete table");
140
+ });
141
+
142
+ it("defaults to the full surface when no capability is passed", () => {
143
+ const { container } = render(makeEditor().editor);
144
+ expect(menuLabels(container)).toEqual(
145
+ menuLabels(render(makeEditor().editor, "full").container),
146
+ );
147
+ });
148
+
149
+ it("the capability filter touches table actions only — every row and column action survives", () => {
150
+ const full = menuLabels(render(makeEditor().editor, "full").container);
151
+ const markdown = menuLabels(render(makeEditor().editor, "markdown").container);
152
+
153
+ for (const label of ROW_AND_COLUMN) {
154
+ expect(full).toContain(label);
155
+ expect(markdown).toContain(label);
156
+ }
157
+ expect(full.length - markdown.length).toBe(2);
158
+ });
159
+
160
+ it("the quick buttons add after the current row and column, not before", () => {
161
+ const { editor, names } = makeEditor();
162
+ const { container } = render(editor);
163
+
164
+ fireEvent.click(container.querySelector('[aria-label="Add row"]')!);
165
+ expect(names()).toEqual(["focus", "addRowAfter", "run"]);
166
+
167
+ fireEvent.click(container.querySelector('[aria-label="Add column"]')!);
168
+ expect(names()).toEqual(["focus", "addRowAfter", "run", "focus", "addColumnAfter", "run"]);
169
+ });
170
+
171
+ it("each menu item runs its own command", () => {
172
+ const cases: Array<[string, string]> = [
173
+ ["Add row above", "addRowBefore"],
174
+ ["Delete row", "deleteRow"],
175
+ ["Add column before", "addColumnBefore"],
176
+ ["Delete column", "deleteColumn"],
177
+ ["Toggle header row", "toggleHeaderRow"],
178
+ ["Merge cells", "mergeCells"],
179
+ ["Split cell", "splitCell"],
180
+ ];
181
+
182
+ for (const [label, command] of cases) {
183
+ const { editor, names } = makeEditor();
184
+ const { container } = render(editor, "full");
185
+ pressInMenu(container, label);
186
+ expect(names()).toEqual(["focus", command, "run"]);
187
+ }
188
+ });
189
+ });