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
69 changes: 65 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 125 additions & 0 deletions src/components/code/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use client';

import React, { useState, useRef } from 'react';
import { Copy, Check, ChevronDown, ChevronUp } from 'lucide-react';
import { SyntaxHighlighter } from './SyntaxHighlighter';

interface CodeBlockProps {
code: string;
language?: string;
/** Max visible lines before collapse toggle appears. Default: 15 */
collapseThreshold?: number;
className?: string;
}

export const CodeBlock: React.FC<CodeBlockProps> = ({
code,
language = 'javascript',
collapseThreshold = 15,
className = '',
}) => {
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const lineCount = code.split('\n').length;
const isCollapsible = lineCount > collapseThreshold;

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard unavailable — silently ignore
}
};

return (
<div
className={`relative rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-950 shadow-md ${className}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
<SyntaxHighlighter language={language} size="sm" />

{/* Copy button */}
<button
onClick={handleCopy}
aria-label={copied ? 'Copied!' : 'Copy code'}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium
text-gray-400 hover:text-white hover:bg-gray-700
transition-colors duration-150 focus:outline-none focus-visible:ring-2
focus-visible:ring-indigo-500"
>
{/* Icon transitions */}
<span
className={`transition-all duration-200 ${
copied ? 'opacity-0 scale-75' : 'opacity-100 scale-100'
} absolute`}
>
<Copy className="w-3.5 h-3.5" />
</span>
<span
className={`transition-all duration-200 ${
copied ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
} absolute`}
>
<Check className="w-3.5 h-3.5 text-emerald-400" />
</span>
{/* Spacer to keep button width stable */}
<span className="w-3.5 h-3.5 inline-block" aria-hidden />
<span className={`transition-colors duration-200 ${copied ? 'text-emerald-400' : ''}`}>
{copied ? 'Copied!' : 'Copy'}
</span>
</button>
</div>

{/* Code area with expand/collapse */}
<div
className="overflow-hidden transition-[max-height] duration-500 ease-in-out"
style={{
maxHeight: isCollapsible && !expanded ? `${collapseThreshold * 1.625}rem` : '9999px',
WebkitMaskImage:
isCollapsible && !expanded
? 'linear-gradient(to bottom, black 60%, transparent 100%)'
: undefined,
maskImage:
isCollapsible && !expanded
? 'linear-gradient(to bottom, black 60%, transparent 100%)'
: undefined,
}}
>
<pre className="overflow-x-auto p-4 text-sm font-mono leading-relaxed text-gray-100 m-0">
<code>{code}</code>
</pre>
</div>

{/* Expand / Collapse toggle */}
{isCollapsible && (
<button
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-medium
text-gray-400 hover:text-white bg-gray-900 hover:bg-gray-800
border-t border-gray-700 transition-colors duration-150
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
>
{expanded ? (
<>
<ChevronUp className="w-3.5 h-3.5 transition-transform duration-300" />
Collapse
</>
) : (
<>
<ChevronDown className="w-3.5 h-3.5 transition-transform duration-300" />
Show {lineCount - collapseThreshold} more line
{lineCount - collapseThreshold !== 1 ? 's' : ''}
</>
)}
</button>
)}
</div>
);
};
86 changes: 86 additions & 0 deletions src/components/code/__tests__/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CodeBlock } from '../CodeBlock';

// Mock SyntaxHighlighter to keep tests focused on CodeBlock behaviour
vi.mock('../SyntaxHighlighter', () => ({
SyntaxHighlighter: ({ language }: { language: string }) => (
<span data-testid="syntax-highlighter">{language}</span>
),
}));

const SHORT_CODE = 'const x = 1;';
const LONG_CODE = Array.from({ length: 20 }, (_, i) => `const line${i} = ${i};`).join('\n');

describe('CodeBlock', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});

it('renders code content', () => {
render(<CodeBlock code={SHORT_CODE} />);
expect(screen.getByText(SHORT_CODE)).toBeInTheDocument();
});

it('renders language badge via SyntaxHighlighter', () => {
render(<CodeBlock code={SHORT_CODE} language="typescript" />);
expect(screen.getByTestId('syntax-highlighter')).toHaveTextContent('typescript');
});

it('does not show expand toggle for short code', () => {
render(<CodeBlock code={SHORT_CODE} collapseThreshold={15} />);
expect(screen.queryByRole('button', { name: /show/i })).not.toBeInTheDocument();
});

it('shows expand toggle when line count exceeds collapseThreshold', () => {
render(<CodeBlock code={LONG_CODE} collapseThreshold={15} />);
expect(screen.getByRole('button', { name: /show \d+ more line/i })).toBeInTheDocument();
});

it('toggles expanded state on expand button click', () => {
render(<CodeBlock code={LONG_CODE} collapseThreshold={15} />);
const toggle = screen.getByRole('button', { name: /show \d+ more line/i });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
expect(screen.getByRole('button', { name: /collapse/i })).toHaveAttribute(
'aria-expanded',
'true',
);
});

it('copies code to clipboard and shows Copied! feedback', async () => {
render(<CodeBlock code={SHORT_CODE} />);
const copyBtn = screen.getByRole('button', { name: /copy code/i });
fireEvent.click(copyBtn);
await waitFor(() =>
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument(),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(SHORT_CODE);
});

it('applies custom className to wrapper', () => {
const { container } = render(<CodeBlock code={SHORT_CODE} className="my-custom-class" />);
expect(container.firstChild).toHaveClass('my-custom-class');
});

it('uses collapseThreshold prop for max-height style', () => {
const { container } = render(
<CodeBlock code={LONG_CODE} collapseThreshold={10} />,
);
const codeArea = container.querySelector('[style*="max-height"]') as HTMLElement;
expect(codeArea.style.maxHeight).toBe(`${10 * 1.625}rem`);
});

it('removes max-height constraint when expanded', () => {
const { container } = render(
<CodeBlock code={LONG_CODE} collapseThreshold={10} />,
);
const toggle = screen.getByRole('button', { name: /show \d+ more line/i });
fireEvent.click(toggle);
const codeArea = container.querySelector('[style*="max-height"]') as HTMLElement;
expect(codeArea.style.maxHeight).toBe('9999px');
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"ignoreDeprecations": "6.0",
"ignoreDeprecations": "5.0",
"baseUrl": ".",
"paths": {
"@/*": [
Expand Down
Loading