Skip to content
Merged
281 changes: 280 additions & 1 deletion templates/content/app/components/editor/BubbleToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,43 @@ vi.mock("@/components/ui/tooltip", () => ({
TooltipTrigger: ({ children }: { children: ReactNode }) => children,
}));

describe("BubbleToolbar link shortcut", () => {
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => children,
PopoverTrigger: ({ children }: { children: ReactNode }) => children,
PopoverContent: ({
children,
onCloseAutoFocus,
onEscapeKeyDown,
}: {
children: ReactNode;
onCloseAutoFocus?: (event: Event) => void;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
}) => (
<div>
<button
data-escape-key-down
onClick={() => {
onEscapeKeyDown?.(new KeyboardEvent("keydown", { key: "Escape" }));
}}
/>
<button
data-close-auto-focus
onClick={(clickEvent) => {
const closeEvent = new Event("closeAutoFocus", {
cancelable: true,
});
onCloseAutoFocus?.(closeEvent);
clickEvent.currentTarget.dataset.prevented = String(
closeEvent.defaultPrevented,
);
}}
/>
{children}
</div>
),
}));

describe("BubbleToolbar", () => {
let editor: Editor | null = null;
let root: Root | null = null;
let editorElement: HTMLDivElement | null = null;
Expand Down Expand Up @@ -147,4 +183,247 @@ describe("BubbleToolbar link shortcut", () => {
toolbarElement.querySelector('input[aria-label="editor.pasteLink"]'),
).not.toBeNull();
});

it("shows the active text style and converts a heading to Text by keyboard", () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<h2>Selected heading</h2>",
});
editor.commands.setTextSelection({ from: 1, to: 9 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));

const trigger = toolbarElement.querySelector<HTMLButtonElement>(
'button[aria-label="editor.slash.turnInto: editor.heading2"]',
);
expect(trigger?.textContent).toContain("H2");

const textOption = [
...toolbarElement.querySelectorAll<HTMLButtonElement>(
'button[role="menuitemradio"]',
),
].find((button) => button.textContent?.includes("editor.slash.text"));
act(() => {
textOption!.dispatchEvent(
new MouseEvent("click", {
detail: 0,
bubbles: true,
cancelable: true,
}),
);
});

expect(editor.getHTML()).toContain("<p>Selected heading</p>");
expect(editor.getHTML()).not.toContain("<h2>Selected heading</h2>");
});

it("converts every block in a mixed paragraph and heading selection to Text", () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<p>First block</p><h2>Second block</h2>",
});
editor.commands.setTextSelection({
from: 1,
to: editor.state.doc.content.size - 2,
});
root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));

const textOption = [
...toolbarElement.querySelectorAll<HTMLButtonElement>(
'button[role="menuitemradio"]',
),
].find((button) => button.textContent?.includes("editor.slash.text"));
act(() => textOption!.click());

expect(editor.getHTML()).toContain("<p>First block</p><p>Second block</p>");
expect(editor.getHTML()).not.toContain("<h2>");
});

it("allows close autofocus to restore focus when no style was applied", () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<p>Selected paragraph</p>",
});
editor.commands.setTextSelection({ from: 1, to: 9 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));

const closeAutoFocus = toolbarElement.querySelector<HTMLButtonElement>(
"button[data-close-auto-focus]",
)!;
act(() => closeAutoFocus.click());

expect(closeAutoFocus.dataset.prevented).toBe("false");
});

it("returns focus to the editor when Escape closes the text-style menu", async () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<p>Selected paragraph</p>",
});
editor.commands.setTextSelection({ from: 1, to: 9 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));
const trigger = toolbarElement.querySelector<HTMLButtonElement>(
'button[aria-label="editor.slash.turnInto: editor.slash.text"]',
)!;
trigger.focus();

const escapeKeyDown = toolbarElement.querySelector<HTMLButtonElement>(
"button[data-escape-key-down]",
)!;
const closeAutoFocus = toolbarElement.querySelector<HTMLButtonElement>(
"button[data-close-auto-focus]",
)!;
await act(async () => {
escapeKeyDown.click();
closeAutoFocus.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});

expect(closeAutoFocus.dataset.prevented).toBe("true");
expect(editor.isFocused).toBe(true);
expect(document.activeElement).toBe(editor.view.dom);
});

it.each([5, 6] as const)(
"identifies an H%s block and converts it to Text",
(level) => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: `<h${level}>Selected heading</h${level}>`,
});
editor.commands.setTextSelection({ from: 1, to: 9 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));

const trigger = toolbarElement.querySelector<HTMLButtonElement>(
`button[aria-label="editor.slash.turnInto: editor.heading${level}"]`,
);
expect(trigger?.textContent).toContain(`H${level}`);

const textOption = [
...toolbarElement.querySelectorAll<HTMLButtonElement>(
'button[role="menuitemradio"]',
),
].find((button) => button.textContent?.includes("editor.slash.text"));
act(() => textOption!.click());

expect(editor.getHTML()).toContain("<p>Selected heading</p>");
expect(editor.getHTML()).not.toContain(
`<h${level}>Selected heading</h${level}>`,
);
},
);

it("updates the selector when the active block becomes an H5", () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<p>Selected heading</p>",
});
editor.commands.setTextSelection({ from: 1, to: 9 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));
act(() => editor!.commands.setHeading({ level: 5 }));

const trigger = toolbarElement.querySelector<HTMLButtonElement>(
'button[aria-label="editor.slash.turnInto: editor.heading5"]',
);
expect(trigger?.textContent).toContain("H5");
});

it("sets an exact heading level without toggling the current style off", () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<h3>Stable heading</h3>",
});
editor.commands.setTextSelection({ from: 1, to: 7 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));

const headingOption = [
...toolbarElement.querySelectorAll<HTMLButtonElement>(
'button[role="menuitemradio"]',
),
].find((button) => button.textContent?.includes("editor.heading3"));
expect(headingOption?.getAttribute("aria-checked")).toBe("true");
act(() => headingOption!.click());

expect(editor.getHTML()).toContain("<h3>Stable heading</h3>");
expect(editor.getHTML()).not.toContain("<p>Stable heading</p>");
const closeAutoFocus = toolbarElement.querySelector<HTMLButtonElement>(
"button[data-close-auto-focus]",
)!;
act(() => closeAutoFocus.click());
expect(closeAutoFocus.dataset.prevented).toBe("true");
});

it("sets a heading from a pointer click", () => {
editorElement = document.createElement("div");
toolbarElement = document.createElement("div");
document.body.append(editorElement, toolbarElement);
editor = new Editor({
element: editorElement,
extensions: [StarterKit],
content: "<p>Selected paragraph</p>",
});
editor.commands.setTextSelection({ from: 1, to: 9 });

root = createRoot(toolbarElement);
act(() => root!.render(<BubbleToolbar editor={editor!} />));

const headingOption = [
...toolbarElement.querySelectorAll<HTMLButtonElement>(
'button[role="menuitemradio"]',
),
].find((button) => button.textContent?.includes("editor.heading3"));
editor.commands.blur();
act(() => {
headingOption!.dispatchEvent(
new PointerEvent("pointerdown", {
button: 0,
bubbles: true,
cancelable: true,
}),
);
});

expect(editor.getHTML()).toContain("<h3>Selected paragraph</h3>");
expect(editor.getHTML()).not.toContain("<p>Selected paragraph</p>");
});
});
Loading
Loading