Skip to content

Commit 90fa07d

Browse files
nullcoderclaude
andcommitted
feat: implement CodeMirror editor wrapper component (#54)
- Create CodeEditor component with CodeMirror 6 integration - Use Compartments for dynamic configuration (best practice) - Support for multiple language modes (JS, Python, HTML, CSS, etc.) - Light/dark theme switching with system theme integration - Configurable options: line numbers, word wrap, read-only mode - Performance optimizations for large files (500KB+) - Full TypeScript support with proper types - Comprehensive test coverage - Demo page for testing functionality Features: - Syntax highlighting for all installed language modes - Auto-completion and bracket matching - Search functionality (Cmd/Ctrl + F) - Code folding with gutter controls - Placeholder text support - Custom styling matching design system - Proper monospace font (Geist Mono) - Fixed focus issues with proper update handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4e5fa41 commit 90fa07d

File tree

4 files changed

+671
-0
lines changed

4 files changed

+671
-0
lines changed

app/demo/code-editor/page.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { CodeEditor } from "@/components/ui/code-editor";
5+
6+
const sampleCode = {
7+
javascript: `// JavaScript Example
8+
function fibonacci(n) {
9+
if (n <= 1) return n;
10+
return fibonacci(n - 1) + fibonacci(n - 2);
11+
}
12+
13+
console.log(fibonacci(10)); // 55`,
14+
15+
python: `# Python Example
16+
def fibonacci(n):
17+
if n <= 1:
18+
return n
19+
return fibonacci(n - 1) + fibonacci(n - 2)
20+
21+
print(fibonacci(10)) # 55`,
22+
23+
html: `<!DOCTYPE html>
24+
<html lang="en">
25+
<head>
26+
<meta charset="UTF-8">
27+
<title>Hello World</title>
28+
</head>
29+
<body>
30+
<h1>Hello, World!</h1>
31+
<p>This is a demo of the CodeEditor component.</p>
32+
</body>
33+
</html>`,
34+
};
35+
36+
export default function CodeEditorDemo() {
37+
const [code, setCode] = useState(sampleCode.javascript);
38+
const [language, setLanguage] = useState("javascript");
39+
const [readOnly, setReadOnly] = useState(false);
40+
const [showLineNumbers, setShowLineNumbers] = useState(true);
41+
const [wordWrap, setWordWrap] = useState(false);
42+
const [theme, setTheme] = useState<"light" | "dark" | undefined>(undefined);
43+
44+
return (
45+
<div className="container mx-auto max-w-6xl p-8">
46+
<h1 className="mb-6 text-3xl font-bold">CodeEditor Component Demo</h1>
47+
48+
<div className="mb-6 space-y-4">
49+
<div className="flex flex-wrap gap-4">
50+
<div>
51+
<label className="mb-2 block text-sm font-medium">Language</label>
52+
<select
53+
value={language}
54+
onChange={(e) => {
55+
setLanguage(e.target.value);
56+
setCode(
57+
sampleCode[e.target.value as keyof typeof sampleCode] || ""
58+
);
59+
}}
60+
className="rounded border px-3 py-2"
61+
>
62+
<option value="javascript">JavaScript</option>
63+
<option value="python">Python</option>
64+
<option value="html">HTML</option>
65+
<option value="css">CSS</option>
66+
<option value="json">JSON</option>
67+
<option value="markdown">Markdown</option>
68+
</select>
69+
</div>
70+
71+
<div>
72+
<label className="mb-2 block text-sm font-medium">Theme</label>
73+
<select
74+
value={theme || "system"}
75+
onChange={(e) => {
76+
const value = e.target.value;
77+
setTheme(
78+
value === "system" ? undefined : (value as "light" | "dark")
79+
);
80+
}}
81+
className="rounded border px-3 py-2"
82+
>
83+
<option value="system">System</option>
84+
<option value="light">Light</option>
85+
<option value="dark">Dark</option>
86+
</select>
87+
</div>
88+
</div>
89+
90+
<div className="flex flex-wrap gap-4">
91+
<label className="flex items-center gap-2">
92+
<input
93+
type="checkbox"
94+
checked={readOnly}
95+
onChange={(e) => setReadOnly(e.target.checked)}
96+
/>
97+
Read Only
98+
</label>
99+
100+
<label className="flex items-center gap-2">
101+
<input
102+
type="checkbox"
103+
checked={showLineNumbers}
104+
onChange={(e) => setShowLineNumbers(e.target.checked)}
105+
/>
106+
Show Line Numbers
107+
</label>
108+
109+
<label className="flex items-center gap-2">
110+
<input
111+
type="checkbox"
112+
checked={wordWrap}
113+
onChange={(e) => setWordWrap(e.target.checked)}
114+
/>
115+
Word Wrap
116+
</label>
117+
</div>
118+
</div>
119+
120+
<div className="mb-4">
121+
<h2 className="mb-2 text-xl font-semibold">Editor</h2>
122+
<div className="font-mono">
123+
<CodeEditor
124+
value={code}
125+
onChange={setCode}
126+
language={language}
127+
readOnly={readOnly}
128+
showLineNumbers={showLineNumbers}
129+
wordWrap={wordWrap}
130+
theme={theme}
131+
placeholder="Start typing your code..."
132+
height="400px"
133+
/>
134+
</div>
135+
</div>
136+
137+
<div className="mt-6">
138+
<h3 className="mb-2 text-lg font-semibold">Current Value:</h3>
139+
<pre className="overflow-x-auto rounded bg-gray-100 p-4 text-sm dark:bg-gray-800">
140+
<code>{code}</code>
141+
</pre>
142+
</div>
143+
144+
<div className="mt-6 rounded bg-blue-50 p-4 dark:bg-blue-900/20">
145+
<h3 className="mb-2 text-lg font-semibold">Features:</h3>
146+
<ul className="list-inside list-disc space-y-1">
147+
<li>Syntax highlighting for multiple languages</li>
148+
<li>Light and dark theme support</li>
149+
<li>Line numbers and code folding</li>
150+
<li>Auto-completion and bracket matching</li>
151+
<li>Search functionality (Cmd/Ctrl + F)</li>
152+
<li>Optimized for large files (500KB+)</li>
153+
<li>TypeScript support with full type safety</li>
154+
</ul>
155+
</div>
156+
</div>
157+
);
158+
}

components/ui/code-editor.test.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { CodeEditor } from "./code-editor";
5+
6+
// Mock next-themes
7+
vi.mock("next-themes", () => ({
8+
useTheme: () => ({ theme: "light" }),
9+
}));
10+
11+
describe("CodeEditor", () => {
12+
it("renders with default props", () => {
13+
const { container } = render(<CodeEditor />);
14+
expect(container.firstChild).toBeInTheDocument();
15+
});
16+
17+
it("displays initial value", async () => {
18+
const initialValue = "console.log('Hello, World!');";
19+
render(<CodeEditor value={initialValue} />);
20+
21+
await waitFor(() => {
22+
const content = document.querySelector(".cm-content");
23+
expect(content?.textContent).toContain("Hello, World!");
24+
});
25+
});
26+
27+
it("calls onChange when content is modified", async () => {
28+
const handleChange = vi.fn();
29+
const user = userEvent.setup();
30+
31+
render(<CodeEditor onChange={handleChange} />);
32+
33+
await waitFor(() => {
34+
const editor = document.querySelector(".cm-content");
35+
expect(editor).toBeInTheDocument();
36+
});
37+
38+
const editor = document.querySelector(".cm-content") as HTMLElement;
39+
40+
// Focus and type in the editor
41+
await user.click(editor);
42+
await user.type(editor, "test");
43+
44+
await waitFor(() => {
45+
expect(handleChange).toHaveBeenCalled();
46+
});
47+
});
48+
49+
it("respects readOnly prop", async () => {
50+
const handleChange = vi.fn();
51+
52+
render(
53+
<CodeEditor value="readonly content" readOnly onChange={handleChange} />
54+
);
55+
56+
await waitFor(() => {
57+
const content = document.querySelector(".cm-content");
58+
expect(content).toBeInTheDocument();
59+
expect(content?.getAttribute("aria-readonly")).toBe("true");
60+
});
61+
});
62+
63+
it("shows placeholder when empty", async () => {
64+
const placeholderText = "Type your code...";
65+
66+
render(<CodeEditor placeholder={placeholderText} value="" />);
67+
68+
await waitFor(() => {
69+
const placeholder = document.querySelector(".cm-placeholder");
70+
expect(placeholder?.textContent).toBe(placeholderText);
71+
});
72+
});
73+
74+
it("applies custom className", () => {
75+
const { container } = render(
76+
<CodeEditor className="custom-editor-class" />
77+
);
78+
79+
expect(container.firstChild).toHaveClass("custom-editor-class");
80+
});
81+
82+
it("respects height prop", () => {
83+
const { container } = render(<CodeEditor height="600px" />);
84+
85+
const editorContainer = container.firstChild as HTMLElement;
86+
expect(editorContainer).toBeInTheDocument();
87+
});
88+
89+
it("shows line numbers when enabled", async () => {
90+
render(<CodeEditor showLineNumbers={true} value="line 1\nline 2" />);
91+
92+
await waitFor(() => {
93+
const lineNumbers = document.querySelector(".cm-lineNumbers");
94+
expect(lineNumbers).toBeInTheDocument();
95+
});
96+
});
97+
98+
it("hides line numbers when disabled", async () => {
99+
render(<CodeEditor showLineNumbers={false} value="line 1\nline 2" />);
100+
101+
await waitFor(() => {
102+
const lineNumbers = document.querySelector(".cm-lineNumbers");
103+
expect(lineNumbers).not.toBeInTheDocument();
104+
});
105+
});
106+
107+
it("supports different languages", async () => {
108+
const pythonCode = "def hello():\n print('Hello, World!')";
109+
110+
render(<CodeEditor language="python" value={pythonCode} />);
111+
112+
await waitFor(() => {
113+
const content = document.querySelector(".cm-content");
114+
expect(content?.textContent).toContain("Hello, World!");
115+
});
116+
});
117+
118+
it("updates when value prop changes", async () => {
119+
const { rerender } = render(<CodeEditor value="initial" />);
120+
121+
await waitFor(() => {
122+
const content = document.querySelector(".cm-content");
123+
expect(content?.textContent).toContain("initial");
124+
});
125+
126+
rerender(<CodeEditor value="updated" />);
127+
128+
await waitFor(() => {
129+
const content = document.querySelector(".cm-content");
130+
expect(content?.textContent).toContain("updated");
131+
});
132+
});
133+
134+
it("applies theme override", async () => {
135+
render(<CodeEditor theme="dark" />);
136+
137+
await waitFor(() => {
138+
const editor = document.querySelector(".cm-editor");
139+
expect(editor).toBeInTheDocument();
140+
});
141+
});
142+
143+
it("renders loading state during SSR", () => {
144+
// The component should render during SSR with loading state
145+
const { container } = render(<CodeEditor />);
146+
147+
// Should initially show the container div
148+
expect(container.firstChild).toBeInTheDocument();
149+
150+
// After mount, it should show the editor
151+
waitFor(() => {
152+
const editor = document.querySelector(".cm-editor");
153+
expect(editor).toBeInTheDocument();
154+
});
155+
});
156+
});

0 commit comments

Comments
 (0)