Skip to content

feat: implement CodeMirror editor wrapper component (#54) #75

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 6, 2025
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
158 changes: 158 additions & 0 deletions app/demo/code-editor/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"use client";

import { useState } from "react";
import { CodeEditor } from "@/components/ui/code-editor";

const sampleCode = {
javascript: `// JavaScript Example
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(10)); // 55`,

python: `# Python Example
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(10)) # 55`,

html: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<h1>Hello, World!</h1>
<p>This is a demo of the CodeEditor component.</p>
</body>
</html>`,
};

export default function CodeEditorDemo() {
const [code, setCode] = useState(sampleCode.javascript);
const [language, setLanguage] = useState("javascript");
const [readOnly, setReadOnly] = useState(false);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const [wordWrap, setWordWrap] = useState(false);
const [theme, setTheme] = useState<"light" | "dark" | undefined>(undefined);

return (
<div className="container mx-auto max-w-6xl p-8">
<h1 className="mb-6 text-3xl font-bold">CodeEditor Component Demo</h1>

<div className="mb-6 space-y-4">
<div className="flex flex-wrap gap-4">
<div>
<label className="mb-2 block text-sm font-medium">Language</label>
<select
value={language}
onChange={(e) => {
setLanguage(e.target.value);
setCode(
sampleCode[e.target.value as keyof typeof sampleCode] || ""
);
}}
className="rounded border px-3 py-2"
>
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="json">JSON</option>
<option value="markdown">Markdown</option>
</select>
</div>

<div>
<label className="mb-2 block text-sm font-medium">Theme</label>
<select
value={theme || "system"}
onChange={(e) => {
const value = e.target.value;
setTheme(
value === "system" ? undefined : (value as "light" | "dark")
);
}}
className="rounded border px-3 py-2"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>

<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={readOnly}
onChange={(e) => setReadOnly(e.target.checked)}
/>
Read Only
</label>

<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showLineNumbers}
onChange={(e) => setShowLineNumbers(e.target.checked)}
/>
Show Line Numbers
</label>

<label className="flex items-center gap-2">
<input
type="checkbox"
checked={wordWrap}
onChange={(e) => setWordWrap(e.target.checked)}
/>
Word Wrap
</label>
</div>
</div>

<div className="mb-4">
<h2 className="mb-2 text-xl font-semibold">Editor</h2>
<div className="font-mono">
<CodeEditor
value={code}
onChange={setCode}
language={language}
readOnly={readOnly}
showLineNumbers={showLineNumbers}
wordWrap={wordWrap}
theme={theme}
placeholder="Start typing your code..."
height="400px"
/>
</div>
</div>

<div className="mt-6">
<h3 className="mb-2 text-lg font-semibold">Current Value:</h3>
<pre className="overflow-x-auto rounded bg-gray-100 p-4 text-sm dark:bg-gray-800">
<code>{code}</code>
</pre>
</div>

<div className="mt-6 rounded bg-blue-50 p-4 dark:bg-blue-900/20">
<h3 className="mb-2 text-lg font-semibold">Features:</h3>
<ul className="list-inside list-disc space-y-1">
<li>Syntax highlighting for multiple languages</li>
<li>Light and dark theme support</li>
<li>Line numbers and code folding</li>
<li>Auto-completion and bracket matching</li>
<li>Search functionality (Cmd/Ctrl + F)</li>
<li>Optimized for large files (500KB+)</li>
<li>TypeScript support with full type safety</li>
</ul>
</div>
</div>
);
}
156 changes: 156 additions & 0 deletions components/ui/code-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, it, expect, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CodeEditor } from "./code-editor";

// Mock next-themes
vi.mock("next-themes", () => ({
useTheme: () => ({ theme: "light" }),
}));

describe("CodeEditor", () => {
it("renders with default props", () => {
const { container } = render(<CodeEditor />);
expect(container.firstChild).toBeInTheDocument();
});

it("displays initial value", async () => {
const initialValue = "console.log('Hello, World!');";
render(<CodeEditor value={initialValue} />);

await waitFor(() => {
const content = document.querySelector(".cm-content");
expect(content?.textContent).toContain("Hello, World!");
});
});

it("calls onChange when content is modified", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();

render(<CodeEditor onChange={handleChange} />);

await waitFor(() => {
const editor = document.querySelector(".cm-content");
expect(editor).toBeInTheDocument();
});

const editor = document.querySelector(".cm-content") as HTMLElement;

// Focus and type in the editor
await user.click(editor);
await user.type(editor, "test");

await waitFor(() => {
expect(handleChange).toHaveBeenCalled();
});
});

it("respects readOnly prop", async () => {
const handleChange = vi.fn();

render(
<CodeEditor value="readonly content" readOnly onChange={handleChange} />
);

await waitFor(() => {
const content = document.querySelector(".cm-content");
expect(content).toBeInTheDocument();
expect(content?.getAttribute("aria-readonly")).toBe("true");
});
});

it("shows placeholder when empty", async () => {
const placeholderText = "Type your code...";

render(<CodeEditor placeholder={placeholderText} value="" />);

await waitFor(() => {
const placeholder = document.querySelector(".cm-placeholder");
expect(placeholder?.textContent).toBe(placeholderText);
});
});

it("applies custom className", () => {
const { container } = render(
<CodeEditor className="custom-editor-class" />
);

expect(container.firstChild).toHaveClass("custom-editor-class");
});

it("respects height prop", () => {
const { container } = render(<CodeEditor height="600px" />);

const editorContainer = container.firstChild as HTMLElement;
expect(editorContainer).toBeInTheDocument();
});

it("shows line numbers when enabled", async () => {
render(<CodeEditor showLineNumbers={true} value="line 1\nline 2" />);

await waitFor(() => {
const lineNumbers = document.querySelector(".cm-lineNumbers");
expect(lineNumbers).toBeInTheDocument();
});
});

it("hides line numbers when disabled", async () => {
render(<CodeEditor showLineNumbers={false} value="line 1\nline 2" />);

await waitFor(() => {
const lineNumbers = document.querySelector(".cm-lineNumbers");
expect(lineNumbers).not.toBeInTheDocument();
});
});

it("supports different languages", async () => {
const pythonCode = "def hello():\n print('Hello, World!')";

render(<CodeEditor language="python" value={pythonCode} />);

await waitFor(() => {
const content = document.querySelector(".cm-content");
expect(content?.textContent).toContain("Hello, World!");
});
});

it("updates when value prop changes", async () => {
const { rerender } = render(<CodeEditor value="initial" />);

await waitFor(() => {
const content = document.querySelector(".cm-content");
expect(content?.textContent).toContain("initial");
});

rerender(<CodeEditor value="updated" />);

await waitFor(() => {
const content = document.querySelector(".cm-content");
expect(content?.textContent).toContain("updated");
});
});

it("applies theme override", async () => {
render(<CodeEditor theme="dark" />);

await waitFor(() => {
const editor = document.querySelector(".cm-editor");
expect(editor).toBeInTheDocument();
});
});

it("renders loading state during SSR", () => {
// The component should render during SSR with loading state
const { container } = render(<CodeEditor />);

// Should initially show the container div
expect(container.firstChild).toBeInTheDocument();

// After mount, it should show the editor
waitFor(() => {
const editor = document.querySelector(".cm-editor");
expect(editor).toBeInTheDocument();
});
});
});
Loading