/** * Bubble menu tests. * * Tests the inline formatting bubble menu that appears when text is selected. * Covers formatting buttons (bold, italic, underline, strikethrough, code), * link insertion/editing, and menu visibility. * * The bubble menu uses TipTap's BubbleMenu component and only appears * when there's a text selection in the editor. */ import type { Editor } from "@tiptap/react"; import { userEvent } from "@vitest/browser/context"; import { describe, it, expect, vi } from "vitest"; import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor"; import { PortableTextEditor } from "../../src/components/PortableTextEditor"; import { render } from "../utils/render.tsx"; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- vi.mock("../../src/components/MediaPickerModal", () => ({ MediaPickerModal: () => null, })); vi.mock("../../src/components/SectionPickerModal", () => ({ SectionPickerModal: () => null, })); vi.mock("../../src/components/editor/DragHandleWrapper", () => ({ DragHandleWrapper: () => null, })); vi.mock("../../src/components/editor/ImageNode", async () => { const { Node } = await import("@tiptap/core"); const ImageExtension = Node.create({ name: "image", group: "block", atom: true, addAttributes() { return { src: { default: null }, alt: { default: "" }, title: { default: "" }, caption: { default: "" }, mediaId: { default: null }, provider: { default: "local" }, width: { default: null }, height: { default: null }, displayWidth: { default: null }, displayHeight: { default: null }, }; }, parseHTML() { return [{ tag: "img[src]" }]; }, renderHTML({ HTMLAttributes }) { return ["img", HTMLAttributes]; }, }); return { ImageExtension }; }); vi.mock("../../src/components/editor/PluginBlockNode", async () => { const { Node } = await import("@tiptap/core"); const PluginBlockExtension = Node.create({ name: "pluginBlock", group: "block", atom: true, addAttributes() { return { blockType: { default: "embed" }, id: { default: "" }, data: { default: {} }, }; }, parseHTML() { return [{ tag: "div[data-plugin-block]" }]; }, renderHTML({ HTMLAttributes }) { return ["div", { ...HTMLAttributes, "data-plugin-block": "" }]; }, }); return { PluginBlockExtension, getEmbedMeta: () => ({ label: "Embed", Icon: () => null }), registerPluginBlocks: () => {}, resolveIcon: () => () => null, }; }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const defaultValue = [ { _type: "block" as const, _key: "1", style: "normal" as const, children: [{ _type: "span" as const, _key: "s1", text: "Hello world" }], }, ]; async function renderEditor(props: Partial = {}) { let editorInstance: Editor | null = null; const screen = await render( { editorInstance = editor; }} {...props} />, ); await vi.waitFor( () => { expect(document.querySelector(".ProseMirror")).toBeTruthy(); expect(editorInstance).toBeTruthy(); }, { timeout: 3000 }, ); const pm = document.querySelector(".ProseMirror") as HTMLElement; return { screen, editor: editorInstance!, pm }; } /** Focus the editor and select all text using TipTap commands */ async function focusAndSelectAll(editor: Editor, pm: HTMLElement) { pm.focus(); await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 }); editor.commands.focus(); editor.commands.selectAll(); } /** * Find the bubble menu element. * TipTap's BubbleMenu renders as a div with role=presentation (tippy.js). * Our menu has the class "bg-kumo-base" and contains aria-label buttons. */ function getBubbleMenu(): HTMLElement | null { // The BubbleMenu from @tiptap/react/menus renders inline. // Look for the container with our known class pattern. const candidates = document.querySelectorAll('[class*="bg-kumo-base"]'); for (const el of candidates) { // Bubble menu has formatting buttons with specific aria-labels if (el.querySelector('[aria-label="Bold"]') && el.querySelector('[aria-label="Italic"]')) { return el as HTMLElement; } } // Also check for link input mode (has Apply link button) for (const el of candidates) { if (el.querySelector('[aria-label="Apply link"]')) { return el as HTMLElement; } } return null; } /** Wait for bubble menu to appear */ async function waitForBubbleMenu(): Promise { let menu: HTMLElement | null = null; await vi.waitFor( () => { menu = getBubbleMenu(); expect(menu).toBeTruthy(); }, { timeout: 3000 }, ); return menu!; } /** Get a bubble menu button by aria-label */ function getBubbleButton(menu: HTMLElement, label: string): HTMLButtonElement | null { return menu.querySelector(`[aria-label="${label}"]`); } // ============================================================================= // Bubble Menu // ============================================================================= describe("Bubble Menu", () => { it("appears when text is selected", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); expect(menu).toBeTruthy(); }); it("shows formatting buttons: Bold, Italic, Underline, Strikethrough, Code", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); expect(getBubbleButton(menu, "Bold")).toBeTruthy(); expect(getBubbleButton(menu, "Italic")).toBeTruthy(); expect(getBubbleButton(menu, "Underline")).toBeTruthy(); expect(getBubbleButton(menu, "Strikethrough")).toBeTruthy(); expect(getBubbleButton(menu, "Code")).toBeTruthy(); }); it("shows Add link button", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); expect(getBubbleButton(menu, "Add link")).toBeTruthy(); }); it("toggles bold when Bold button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const boldBtn = getBubbleButton(menu, "Bold")!; boldBtn.click(); await vi.waitFor(() => { expect(editor.isActive("bold")).toBe(true); }); // Verify the text is wrapped in expect(pm.querySelector("strong")).toBeTruthy(); }); it("toggles italic when Italic button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const italicBtn = getBubbleButton(menu, "Italic")!; italicBtn.click(); await vi.waitFor(() => { expect(editor.isActive("italic")).toBe(true); }); expect(pm.querySelector("em")).toBeTruthy(); }); it("toggles underline when Underline button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const underlineBtn = getBubbleButton(menu, "Underline")!; underlineBtn.click(); await vi.waitFor(() => { expect(editor.isActive("underline")).toBe(true); }); expect(pm.querySelector("u")).toBeTruthy(); }); it("toggles strikethrough when Strikethrough button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const strikeBtn = getBubbleButton(menu, "Strikethrough")!; strikeBtn.click(); await vi.waitFor(() => { expect(editor.isActive("strike")).toBe(true); }); expect(pm.querySelector("s")).toBeTruthy(); }); it("toggles inline code when Code button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const codeBtn = getBubbleButton(menu, "Code")!; codeBtn.click(); await vi.waitFor(() => { expect(editor.isActive("code")).toBe(true); }); expect(pm.querySelector("code")).toBeTruthy(); }); it("shows link input when Add link button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const linkBtn = getBubbleButton(menu, "Add link")!; linkBtn.click(); // The bubble menu should now show the link input await vi.waitFor(() => { const applyBtn = getBubbleButton(menu, "Apply link"); expect(applyBtn).toBeTruthy(); }); // Should have a URL input with placeholder const input = menu.querySelector('input[type="url"]'); expect(input).toBeTruthy(); }); it("applies link URL when Apply button is clicked", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const linkBtn = getBubbleButton(menu, "Add link")!; linkBtn.click(); await vi.waitFor(() => { expect(menu.querySelector('input[type="url"]')).toBeTruthy(); }); // Type a URL into the input const input = menu.querySelector('input[type="url"]') as HTMLInputElement; input.focus(); // Use native value setter + input event for React controlled input const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(input, "https://example.com"); input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); // Click Apply const applyBtn = getBubbleButton(menu, "Apply link")!; applyBtn.click(); // The editor should now have a link await vi.waitFor(() => { const link = pm.querySelector("a"); expect(link).toBeTruthy(); expect(link!.getAttribute("href")).toBe("https://example.com"); }); }); it("applies link on Enter key in URL input", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); getBubbleButton(menu, "Add link")!.click(); await vi.waitFor(() => { expect(menu.querySelector('input[type="url"]')).toBeTruthy(); }); const input = menu.querySelector('input[type="url"]') as HTMLInputElement; input.focus(); const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(input, "https://test.org"); input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); // Press Enter await userEvent.keyboard("{Enter}"); await vi.waitFor(() => { const link = pm.querySelector("a"); expect(link).toBeTruthy(); expect(link!.getAttribute("href")).toBe("https://test.org"); }); }); it("shows Edit link and Remove link buttons when cursor is on a link", async () => { const linkValue = [ { _type: "block" as const, _key: "1", style: "normal" as const, children: [ { _type: "span" as const, _key: "s1", text: "Click here", marks: ["link1"], }, ], markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }], }, ]; const { editor, pm } = await renderEditor({ value: linkValue }); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); // Should show "Edit link" instead of "Add link" expect(getBubbleButton(menu, "Edit link")).toBeTruthy(); // Click Edit link to show input getBubbleButton(menu, "Edit link")!.click(); await vi.waitFor(() => { expect(getBubbleButton(menu, "Remove link")).toBeTruthy(); }); }); it("removes link when Remove link button is clicked", async () => { const linkValue = [ { _type: "block" as const, _key: "1", style: "normal" as const, children: [ { _type: "span" as const, _key: "s1", text: "Click here", marks: ["link1"], }, ], markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }], }, ]; const { editor, pm } = await renderEditor({ value: linkValue }); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); // Click Edit link to open link input mode getBubbleButton(menu, "Edit link")!.click(); await vi.waitFor(() => { expect(getBubbleButton(menu, "Remove link")).toBeTruthy(); }); // Click Remove link getBubbleButton(menu, "Remove link")!.click(); await vi.waitFor(() => { expect(pm.querySelector("a")).toBeNull(); }); }); it("closes link input on Escape and returns focus to editor", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); getBubbleButton(menu, "Add link")!.click(); await vi.waitFor(() => { expect(menu.querySelector('input[type="url"]')).toBeTruthy(); }); const input = menu.querySelector('input[type="url"]') as HTMLInputElement; input.focus(); // Press Escape await userEvent.keyboard("{Escape}"); // Should return to the formatting buttons view await vi.waitFor(() => { expect(getBubbleButton(menu, "Bold")).toBeTruthy(); }); }); it("unsets link when Apply is clicked with empty URL", async () => { const linkValue = [ { _type: "block" as const, _key: "1", style: "normal" as const, children: [ { _type: "span" as const, _key: "s1", text: "Click here", marks: ["link1"], }, ], markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }], }, ]; const { editor, pm } = await renderEditor({ value: linkValue }); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); getBubbleButton(menu, "Edit link")!.click(); await vi.waitFor(() => { expect(menu.querySelector('input[type="url"]')).toBeTruthy(); }); // Clear the input const input = menu.querySelector('input[type="url"]') as HTMLInputElement; input.focus(); const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(input, ""); input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); // Click Apply getBubbleButton(menu, "Apply link")!.click(); // Link should be removed await vi.waitFor(() => { expect(pm.querySelector("a")).toBeNull(); }); }); it("can apply multiple formatting marks", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); // Apply bold + italic getBubbleButton(menu, "Bold")!.click(); getBubbleButton(menu, "Italic")!.click(); await vi.waitFor(() => { expect(editor.isActive("bold")).toBe(true); expect(editor.isActive("italic")).toBe(true); }); expect(pm.querySelector("strong")).toBeTruthy(); expect(pm.querySelector("em")).toBeTruthy(); }); it("toggles off formatting when clicked twice", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); const menu = await waitForBubbleMenu(); const boldBtn = getBubbleButton(menu, "Bold")!; // Apply bold boldBtn.click(); await vi.waitFor(() => expect(editor.isActive("bold")).toBe(true)); // Re-select (bold toggle may deselect) editor.commands.selectAll(); // Remove bold boldBtn.click(); await vi.waitFor(() => expect(editor.isActive("bold")).toBe(false)); expect(pm.querySelector("strong")).toBeNull(); }); });