Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,12 @@
.editor-checklist-checked:focus::before {
box-shadow: 0 0 0 2px var(--ring);
}

/*
* Collapsible toggle chevron rotation.
* Rotates the chevron 90° when the parent <details> is open.
* Tailwind cannot target details[open] > summary > button.
*/
details[open] > summary > .collapsible-toggle {
transform: rotate(90deg);
}
18 changes: 16 additions & 2 deletions src/components/editor/collapsible-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export class CollapsibleContainerNode extends ElementNode {

createDOM(): HTMLElement {
const details = document.createElement("details");
details.className = "mt-3 border border-white/[0.06] text-sm";
details.className =
"mt-3 border border-white/[0.06] text-sm rounded-sm";
if (this.__open) {
details.open = true;
}
Expand Down Expand Up @@ -123,7 +124,20 @@ export class CollapsibleTitleNode extends ElementNode {
createDOM(): HTMLElement {
const summary = document.createElement("summary");
summary.className =
"cursor-pointer select-none p-3 text-sm font-medium text-foreground hover:bg-white/[0.04] list-none";
"flex items-center gap-1.5 p-3 text-sm font-medium text-foreground hover:bg-white/[0.04] list-none";

// Add a toggle chevron button as the visual affordance.
// The chevron rotates when the parent <details> is open.
const chevron = document.createElement("button");
chevron.type = "button";
chevron.contentEditable = "false";
chevron.className =
"collapsible-toggle flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.08] transition-transform duration-150";
chevron.setAttribute("aria-label", "Toggle section");
chevron.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
summary.prepend(chevron);

return summary;
}

Expand Down
54 changes: 47 additions & 7 deletions src/components/editor/collapsible-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export function CollapsiblePlugin(): null {
);
}, [editor]);

// Handle toggle open/close via DOM events on <details>
// Handle toggle open/close via the chevron button.
// The native <summary> click toggles <details>, which conflicts with
// Lexical text editing in the title. We prevent the native toggle on
// summary clicks and instead toggle only when the chevron is clicked.
useEffect(() => {
const cleanupMap = new Map<string, () => void>();

Expand All @@ -102,19 +105,56 @@ export function CollapsiblePlugin(): null {
) as HTMLDetailsElement | null;
if (!dom) continue;

const handleToggle = () => {
const summary = dom.querySelector("summary");
if (!summary) continue;

const chevron = summary.querySelector(
".collapsible-toggle"
) as HTMLButtonElement | null;

// Prevent the native <details> toggle when clicking the summary
// text area — this allows Lexical to handle cursor placement
// and text editing without the section collapsing.
const handleSummaryClick = (e: MouseEvent) => {
const target = e.target;
// Allow the chevron button (and its SVG children) to toggle
if (
chevron &&
target instanceof Node &&
(chevron === target || chevron.contains(target))
) {
return;
}
// Prevent native toggle for all other summary clicks (text editing)
e.preventDefault();
};

// Toggle via the chevron button only
const handleChevronClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if (node instanceof CollapsibleContainerNode) {
node.setOpen(dom.open);
const newOpen = !node.getOpen();
node.setOpen(newOpen);
// Sync the DOM immediately since we prevented native toggle
dom.open = newOpen;
}
});
};

dom.addEventListener("toggle", handleToggle);
cleanupMap.set(nodeKey, () =>
dom.removeEventListener("toggle", handleToggle)
);
summary.addEventListener("click", handleSummaryClick);
if (chevron) {
chevron.addEventListener("click", handleChevronClick);
}

cleanupMap.set(nodeKey, () => {
summary.removeEventListener("click", handleSummaryClick);
if (chevron) {
chevron.removeEventListener("click", handleChevronClick);
}
});
}
}
);
Expand Down
89 changes: 89 additions & 0 deletions src/components/editor/collapsible-toggle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";

/**
* Regression tests for issue #206: toggle blocks not functioning as toggles.
*
* The root cause was threefold:
* 1. No visual toggle indicator (chevron) — list-none removed the browser
* disclosure triangle with no replacement.
* 2. select-none on <summary> prevented text editing in the title.
* 3. Native <summary> click toggled <details>, conflicting with Lexical
* text editing — clicking to place cursor collapsed the section.
*
* These static tests verify the structural fixes remain in place.
*/

function readSource(relativePath: string): string {
return readFileSync(resolve(__dirname, relativePath), "utf-8");
}

describe("collapsible-node toggle affordance", () => {
const source = readSource("./collapsible-node.tsx");

it("summary does not use select-none (title must be editable)", () => {
// select-none prevents cursor placement and text selection in the
// title, breaking inline editing.
const summaryClass = source.match(/summary\.className\s*=\s*\n?\s*"([^"]*)"/);
expect(summaryClass).not.toBeNull();
expect(summaryClass![1]).not.toContain("select-none");
});

it("summary includes a toggle chevron button", () => {
// A dedicated button with class collapsible-toggle must be created
// as the visual affordance for expand/collapse.
expect(source).toContain("collapsible-toggle");
expect(source).toContain('aria-label", "Toggle section"');
});

it("chevron button is not contentEditable", () => {
// The chevron must be excluded from Lexical's editable content
// so it doesn't interfere with text editing.
expect(source).toContain('contentEditable = "false"');
});

it("summary uses flex layout for chevron + text alignment", () => {
const summaryClass = source.match(/summary\.className\s*=\s*\n?\s*"([^"]*)"/);
expect(summaryClass).not.toBeNull();
expect(summaryClass![1]).toContain("flex");
expect(summaryClass![1]).toContain("items-center");
});
});

describe("collapsible-plugin toggle handling", () => {
const source = readSource("./collapsible-plugin.tsx");

it("prevents native summary click from toggling details", () => {
// The plugin must call preventDefault on summary clicks (except
// on the chevron) to stop the native <details> toggle from
// conflicting with Lexical text editing.
expect(source).toContain("e.preventDefault()");
expect(source).toContain("handleSummaryClick");
});

it("toggles via chevron button click only", () => {
// A dedicated chevron click handler must toggle the node state
// and sync the DOM.
expect(source).toContain("handleChevronClick");
expect(source).toContain("collapsible-toggle");
});

it("syncs DOM open state when toggling via chevron", () => {
// After updating the Lexical node, the DOM must be synced since
// we prevented the native toggle behavior.
expect(source).toContain("dom.open = newOpen");
});
});

describe("collapsible chevron CSS rotation", () => {
const css = readFileSync(
resolve(__dirname, "../../app/globals.css"),
"utf-8"
);

it("rotates chevron when details is open", () => {
expect(css).toContain("details[open] > summary > .collapsible-toggle");
expect(css).toContain("rotate(90deg)");
});
});
Loading