Skip to content

Commit e8135b0

Browse files
fix(editor): toggle blocks not functioning as actual toggles (#206) (#217)
- Add dedicated chevron button as visual toggle affordance - Prevent native <summary> click from toggling <details> so title text remains editable via Lexical - Remove select-none from summary to allow cursor placement - Use instanceof guard for event target (node-contains-safety) - Add CSS rotation for chevron when details is open - Add static regression tests for structural fixes Co-authored-by: Ona <no-reply@ona.com>
1 parent 95b73ce commit e8135b0

4 files changed

Lines changed: 161 additions & 9 deletions

File tree

src/app/globals.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,12 @@
204204
.editor-checklist-checked:focus::before {
205205
box-shadow: 0 0 0 2px var(--ring);
206206
}
207+
208+
/*
209+
* Collapsible toggle chevron rotation.
210+
* Rotates the chevron 90° when the parent <details> is open.
211+
* Tailwind cannot target details[open] > summary > button.
212+
*/
213+
details[open] > summary > .collapsible-toggle {
214+
transform: rotate(90deg);
215+
}

src/components/editor/collapsible-node.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ export class CollapsibleContainerNode extends ElementNode {
5454

5555
createDOM(): HTMLElement {
5656
const details = document.createElement("details");
57-
details.className = "mt-3 border border-white/[0.06] text-sm";
57+
details.className =
58+
"mt-3 border border-white/[0.06] text-sm rounded-sm";
5859
if (this.__open) {
5960
details.open = true;
6061
}
@@ -123,7 +124,20 @@ export class CollapsibleTitleNode extends ElementNode {
123124
createDOM(): HTMLElement {
124125
const summary = document.createElement("summary");
125126
summary.className =
126-
"cursor-pointer select-none p-3 text-sm font-medium text-foreground hover:bg-white/[0.04] list-none";
127+
"flex items-center gap-1.5 p-3 text-sm font-medium text-foreground hover:bg-white/[0.04] list-none";
128+
129+
// Add a toggle chevron button as the visual affordance.
130+
// The chevron rotates when the parent <details> is open.
131+
const chevron = document.createElement("button");
132+
chevron.type = "button";
133+
chevron.contentEditable = "false";
134+
chevron.className =
135+
"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";
136+
chevron.setAttribute("aria-label", "Toggle section");
137+
chevron.innerHTML =
138+
'<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>';
139+
summary.prepend(chevron);
140+
127141
return summary;
128142
}
129143

src/components/editor/collapsible-plugin.tsx

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export function CollapsiblePlugin(): null {
7777
);
7878
}, [editor]);
7979

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

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

105-
const handleToggle = () => {
108+
const summary = dom.querySelector("summary");
109+
if (!summary) continue;
110+
111+
const chevron = summary.querySelector(
112+
".collapsible-toggle"
113+
) as HTMLButtonElement | null;
114+
115+
// Prevent the native <details> toggle when clicking the summary
116+
// text area — this allows Lexical to handle cursor placement
117+
// and text editing without the section collapsing.
118+
const handleSummaryClick = (e: MouseEvent) => {
119+
const target = e.target;
120+
// Allow the chevron button (and its SVG children) to toggle
121+
if (
122+
chevron &&
123+
target instanceof Node &&
124+
(chevron === target || chevron.contains(target))
125+
) {
126+
return;
127+
}
128+
// Prevent native toggle for all other summary clicks (text editing)
129+
e.preventDefault();
130+
};
131+
132+
// Toggle via the chevron button only
133+
const handleChevronClick = (e: MouseEvent) => {
134+
e.preventDefault();
135+
e.stopPropagation();
106136
editor.update(() => {
107137
const node = $getNodeByKey(nodeKey);
108138
if (node instanceof CollapsibleContainerNode) {
109-
node.setOpen(dom.open);
139+
const newOpen = !node.getOpen();
140+
node.setOpen(newOpen);
141+
// Sync the DOM immediately since we prevented native toggle
142+
dom.open = newOpen;
110143
}
111144
});
112145
};
113146

114-
dom.addEventListener("toggle", handleToggle);
115-
cleanupMap.set(nodeKey, () =>
116-
dom.removeEventListener("toggle", handleToggle)
117-
);
147+
summary.addEventListener("click", handleSummaryClick);
148+
if (chevron) {
149+
chevron.addEventListener("click", handleChevronClick);
150+
}
151+
152+
cleanupMap.set(nodeKey, () => {
153+
summary.removeEventListener("click", handleSummaryClick);
154+
if (chevron) {
155+
chevron.removeEventListener("click", handleChevronClick);
156+
}
157+
});
118158
}
119159
}
120160
);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { resolve } from "path";
4+
5+
/**
6+
* Regression tests for issue #206: toggle blocks not functioning as toggles.
7+
*
8+
* The root cause was threefold:
9+
* 1. No visual toggle indicator (chevron) — list-none removed the browser
10+
* disclosure triangle with no replacement.
11+
* 2. select-none on <summary> prevented text editing in the title.
12+
* 3. Native <summary> click toggled <details>, conflicting with Lexical
13+
* text editing — clicking to place cursor collapsed the section.
14+
*
15+
* These static tests verify the structural fixes remain in place.
16+
*/
17+
18+
function readSource(relativePath: string): string {
19+
return readFileSync(resolve(__dirname, relativePath), "utf-8");
20+
}
21+
22+
describe("collapsible-node toggle affordance", () => {
23+
const source = readSource("./collapsible-node.tsx");
24+
25+
it("summary does not use select-none (title must be editable)", () => {
26+
// select-none prevents cursor placement and text selection in the
27+
// title, breaking inline editing.
28+
const summaryClass = source.match(/summary\.className\s*=\s*\n?\s*"([^"]*)"/);
29+
expect(summaryClass).not.toBeNull();
30+
expect(summaryClass![1]).not.toContain("select-none");
31+
});
32+
33+
it("summary includes a toggle chevron button", () => {
34+
// A dedicated button with class collapsible-toggle must be created
35+
// as the visual affordance for expand/collapse.
36+
expect(source).toContain("collapsible-toggle");
37+
expect(source).toContain('aria-label", "Toggle section"');
38+
});
39+
40+
it("chevron button is not contentEditable", () => {
41+
// The chevron must be excluded from Lexical's editable content
42+
// so it doesn't interfere with text editing.
43+
expect(source).toContain('contentEditable = "false"');
44+
});
45+
46+
it("summary uses flex layout for chevron + text alignment", () => {
47+
const summaryClass = source.match(/summary\.className\s*=\s*\n?\s*"([^"]*)"/);
48+
expect(summaryClass).not.toBeNull();
49+
expect(summaryClass![1]).toContain("flex");
50+
expect(summaryClass![1]).toContain("items-center");
51+
});
52+
});
53+
54+
describe("collapsible-plugin toggle handling", () => {
55+
const source = readSource("./collapsible-plugin.tsx");
56+
57+
it("prevents native summary click from toggling details", () => {
58+
// The plugin must call preventDefault on summary clicks (except
59+
// on the chevron) to stop the native <details> toggle from
60+
// conflicting with Lexical text editing.
61+
expect(source).toContain("e.preventDefault()");
62+
expect(source).toContain("handleSummaryClick");
63+
});
64+
65+
it("toggles via chevron button click only", () => {
66+
// A dedicated chevron click handler must toggle the node state
67+
// and sync the DOM.
68+
expect(source).toContain("handleChevronClick");
69+
expect(source).toContain("collapsible-toggle");
70+
});
71+
72+
it("syncs DOM open state when toggling via chevron", () => {
73+
// After updating the Lexical node, the DOM must be synced since
74+
// we prevented the native toggle behavior.
75+
expect(source).toContain("dom.open = newOpen");
76+
});
77+
});
78+
79+
describe("collapsible chevron CSS rotation", () => {
80+
const css = readFileSync(
81+
resolve(__dirname, "../../app/globals.css"),
82+
"utf-8"
83+
);
84+
85+
it("rotates chevron when details is open", () => {
86+
expect(css).toContain("details[open] > summary > .collapsible-toggle");
87+
expect(css).toContain("rotate(90deg)");
88+
});
89+
});

0 commit comments

Comments
 (0)