fix: Make Code more like pre/code elements
This commit is contained in:
7 files changed
+299
-185
No files matched your search
@@ -1,31 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { Code } from './Code';
|
import { Code } from './Code';
|
||||||
|
import { CodeBlock } from './CodeBlock/CodeBlock';
|
||||||
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
const meta: Meta<typeof Code> = {
|
const meta: Meta = {
|
||||||
title: 'Components/Code',
|
title: 'Components/Code',
|
||||||
component: Code,
|
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
argTypes: {
|
|
||||||
variant: {
|
|
||||||
control: 'radio',
|
|
||||||
options: ['block', 'inline'],
|
|
||||||
},
|
|
||||||
showLineNumbers: {
|
|
||||||
control: 'boolean',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
control: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof Code>;
|
|
||||||
|
|
||||||
// Helper to simulate highlighted lines
|
// Helper to simulate highlighted lines
|
||||||
const MockHighlightedCode = () => (
|
const MockHighlightedCode = () => (
|
||||||
<>
|
<Code>
|
||||||
<span className="block text-[var(--rb-primary-text)]">
|
<span className="block text-[var(--rb-primary-text)]">
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
</span>
|
</span>
|
||||||
@@ -33,73 +22,85 @@ const MockHighlightedCode = () => (
|
|||||||
<span className="block text-[var(--rb-secondary-text)]">
|
<span className="block text-[var(--rb-secondary-text)]">
|
||||||
export const MyComponent = () => {
|
export const MyComponent = () => {
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[var(--rb-text)] pl-4">
|
<span className="block text-[var(--rb-text)]">
|
||||||
return <div>Hello World</div>;
|
{' '}return <div>Hello World</div>;
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[var(--rb-secondary-text)]">};</span>
|
<span className="block text-[var(--rb-secondary-text)]">};</span>
|
||||||
</>
|
</Code>
|
||||||
);
|
);
|
||||||
|
|
||||||
const LongHighlightedCode = () => (
|
const LongHighlightedCode = () => (
|
||||||
<>
|
<Code>
|
||||||
{Array.from({ length: 50 }).map((_, i) => (
|
{Array.from({ length: 50 }).map((_, i) => (
|
||||||
<span key={`line-${String(i)}`} className="block">
|
<span key={`line-${String(i)}`} className="block">
|
||||||
<span className="text-[var(--rb-tertiary-text)]">line_{i + 1}</span>:{' '}
|
<span className="text-[var(--rb-tertiary-text)]">line_{i + 1}</span>:{' '}
|
||||||
<span className="text-[var(--rb-text)]">const x = {Math.random()};</span>
|
<span className="text-[var(--rb-text)]">const x = {Math.random()};</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</>
|
</Code>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const DefaultBlock: Story = {
|
export const DefaultBlock: StoryObj<typeof CodeBlock> = {
|
||||||
|
render: (args) => (
|
||||||
|
<CodeBlock {...args}>
|
||||||
|
<MockHighlightedCode />
|
||||||
|
</CodeBlock>
|
||||||
|
),
|
||||||
args: {
|
args: {
|
||||||
children: <MockHighlightedCode />,
|
|
||||||
variant: 'block',
|
|
||||||
label: 'MyComponent.tsx',
|
label: 'MyComponent.tsx',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithoutLabel: Story = {
|
export const WithoutLabel: StoryObj<typeof CodeBlock> = {
|
||||||
|
render: (args) => (
|
||||||
|
<CodeBlock {...args}>
|
||||||
|
<MockHighlightedCode />
|
||||||
|
</CodeBlock>
|
||||||
|
),
|
||||||
args: {
|
args: {
|
||||||
children: <MockHighlightedCode />,
|
|
||||||
variant: 'block',
|
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithoutLineNumbers: Story = {
|
export const WithoutLineNumbers: StoryObj<typeof CodeBlock> = {
|
||||||
|
render: (args) => (
|
||||||
|
<CodeBlock {...args}>
|
||||||
|
<MockHighlightedCode />
|
||||||
|
</CodeBlock>
|
||||||
|
),
|
||||||
args: {
|
args: {
|
||||||
children: <MockHighlightedCode />,
|
|
||||||
variant: 'block',
|
|
||||||
showLineNumbers: false,
|
showLineNumbers: false,
|
||||||
label: 'no-lines.js',
|
label: 'no-lines.js',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Inline: Story = {
|
export const Inline: StoryObj<typeof Code> = {
|
||||||
args: {
|
render: (args) => <Code {...args}>npm install @ermnvldmr/ui</Code>,
|
||||||
children: 'npm install @ermnvldmr/ui',
|
|
||||||
variant: 'inline',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LongWithScroll: Story = {
|
export const LongWithScroll: StoryObj<typeof CodeBlock> = {
|
||||||
|
render: (args) => (
|
||||||
|
<CodeBlock {...args}>
|
||||||
|
<LongHighlightedCode />
|
||||||
|
</CodeBlock>
|
||||||
|
),
|
||||||
args: {
|
args: {
|
||||||
children: <LongHighlightedCode />,
|
|
||||||
variant: 'block',
|
|
||||||
label: 'long-file.ts',
|
label: 'long-file.ts',
|
||||||
maxHeight: '300px',
|
maxHeight: '300px',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PlainText: Story = {
|
export const PlainText: StoryObj<typeof CodeBlock> = {
|
||||||
|
render: (args) => (
|
||||||
|
<CodeBlock {...args}>
|
||||||
|
<Code>
|
||||||
|
{`const simple = "string content";
|
||||||
|
console.log(simple);`}
|
||||||
|
</Code>
|
||||||
|
</CodeBlock>
|
||||||
|
),
|
||||||
args: {
|
args: {
|
||||||
children: `const simple = "string content";
|
|
||||||
console.log(simple);`,
|
|
||||||
variant: 'block',
|
|
||||||
label: 'plain-text.js',
|
label: 'plain-text.js',
|
||||||
// Note: Line numbers won't work automatically on plain text unless we split it into elements
|
|
||||||
// This story demonstrates behavior with raw text (counters will count the single text node as 1 line or 0)
|
|
||||||
showLineNumbers: false,
|
showLineNumbers: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2,9 +2,10 @@ import { render, screen, fireEvent, act } from '@testing-library/react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Code } from './Code';
|
import { Code } from './Code';
|
||||||
|
import { CodeBlock } from './CodeBlock/CodeBlock';
|
||||||
|
|
||||||
// Mock clipboard API
|
// Mock clipboard API
|
||||||
const mockWriteText = jest.fn();
|
const mockWriteText = jest.fn().mockImplementation(() => Promise.resolve());
|
||||||
Object.assign(navigator, {
|
Object.assign(navigator, {
|
||||||
clipboard: {
|
clipboard: {
|
||||||
writeText: mockWriteText,
|
writeText: mockWriteText,
|
||||||
@@ -17,7 +18,7 @@ describe('Code Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders inline code correctly', () => {
|
it('renders inline code correctly', () => {
|
||||||
render(<Code variant="inline">const x = 1;</Code>);
|
render(<Code>const x = 1;</Code>);
|
||||||
const codeElement = screen.getByText('const x = 1;');
|
const codeElement = screen.getByText('const x = 1;');
|
||||||
expect(codeElement.tagName).toBe('CODE');
|
expect(codeElement.tagName).toBe('CODE');
|
||||||
expect(codeElement).toHaveClass('rounded-sm');
|
expect(codeElement).toHaveClass('rounded-sm');
|
||||||
@@ -25,9 +26,11 @@ describe('Code Component', () => {
|
|||||||
|
|
||||||
it('renders block code correctly', () => {
|
it('renders block code correctly', () => {
|
||||||
render(
|
render(
|
||||||
<Code label="test.ts" variant="block">
|
<CodeBlock label="test.ts">
|
||||||
<span>console.log("hello");</span>
|
<Code>
|
||||||
</Code>
|
<span>console.log("hello");</span>
|
||||||
|
</Code>
|
||||||
|
</CodeBlock>
|
||||||
);
|
);
|
||||||
// Should verify it's inside a pre tag
|
// Should verify it's inside a pre tag
|
||||||
const preElement = screen.getByText('console.log("hello");').closest('pre');
|
const preElement = screen.getByText('console.log("hello");').closest('pre');
|
||||||
@@ -37,42 +40,43 @@ describe('Code Component', () => {
|
|||||||
expect(screen.getByText('test.ts')).toBeInTheDocument();
|
expect(screen.getByText('test.ts')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('copies content on click in inline mode', () => {
|
it('copies content on click in inline mode', async () => {
|
||||||
render(
|
render(<Code copyValue="copied!">Display Text</Code>);
|
||||||
<Code copyValue="copied!" variant="inline">
|
|
||||||
Display Text
|
|
||||||
</Code>
|
|
||||||
);
|
|
||||||
const codeElement = screen.getByText('Display Text');
|
const codeElement = screen.getByText('Display Text');
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
fireEvent.click(codeElement);
|
fireEvent.click(codeElement);
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockWriteText).toHaveBeenCalledWith('copied!');
|
expect(mockWriteText).toHaveBeenCalledWith('copied!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('copies content via button in block mode', () => {
|
it('copies content via button in block mode', async () => {
|
||||||
render(
|
render(
|
||||||
<Code copyValue="block copy" variant="block">
|
<CodeBlock copyValue="block copy">
|
||||||
<span>some code</span>
|
<Code>
|
||||||
</Code>
|
<span>some code</span>
|
||||||
|
</Code>
|
||||||
|
</CodeBlock>
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyButton = screen.getByLabelText('Copy code');
|
const copyButton = screen.getByLabelText('Copy code');
|
||||||
act(() => {
|
await act(async () => {
|
||||||
fireEvent.click(copyButton);
|
fireEvent.click(copyButton);
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockWriteText).toHaveBeenCalledWith('block copy');
|
expect(mockWriteText).toHaveBeenCalledWith('block copy');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts text from children if copyValue is not provided', () => {
|
it('extracts text from children if copyValue is not provided', async () => {
|
||||||
render(<Code variant="inline">extracted</Code>);
|
render(<Code>extracted</Code>);
|
||||||
|
|
||||||
const codeElement = screen.getByText('extracted');
|
const codeElement = screen.getByText('extracted');
|
||||||
act(() => {
|
await act(async () => {
|
||||||
fireEvent.click(codeElement);
|
fireEvent.click(codeElement);
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockWriteText).toHaveBeenCalledWith('extracted');
|
expect(mockWriteText).toHaveBeenCalledWith('extracted');
|
||||||
|
|||||||
@@ -1,69 +1,39 @@
|
|||||||
import { cn } from '@ermnvldmr/stl';
|
import { cn } from '@ermnvldmr/stl';
|
||||||
import { Check, Copy } from 'lucide-react';
|
|
||||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { usePress } from 'react-aria';
|
import { usePress } from 'react-aria';
|
||||||
|
|
||||||
import { Button } from '../Button/Button';
|
import { useCodeContext } from './contexts/CodeContext/CodeContext';
|
||||||
import { Container } from '../Container/Container';
|
|
||||||
|
|
||||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Props for the Code component.
|
||||||
*/
|
*/
|
||||||
export interface CodeProps extends ClassNameProps, TestIdProps {
|
export interface CodeProps extends ClassNameProps, TestIdProps {
|
||||||
/** The content to display. Can be a string or pre-highlighted HTML elements. */
|
/** The content to display. Can be a string or pre-highlighted HTML elements. */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display style of the code.
|
* Raw string value to copy to clipboard (only used in inline mode).
|
||||||
* - 'block': Multi-line container with optional scroll, line numbers, and header controls.
|
* If omitted in inline mode, the component will attempt to extract text from `children`.
|
||||||
* - 'inline': Compact, clickable snippet for embedding in text.
|
|
||||||
* @default 'block'
|
|
||||||
*/
|
|
||||||
variant?: 'block' | 'inline';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional label text (e.g., filename "index.ts", language "bash")
|
|
||||||
* displayed in the top-left corner of block variants.
|
|
||||||
*/
|
|
||||||
label?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to render line number gutters in block mode.
|
|
||||||
* Requires children to be rendered as block-level elements (e.g., via a highlighter).
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showLineNumbers?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw string value to copy to clipboard.
|
|
||||||
* If omitted, the component will attempt to extract text from `children`.
|
|
||||||
*/
|
*/
|
||||||
copyValue?: string;
|
copyValue?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum height of the code block before vertical scrolling is enabled.
|
|
||||||
*/
|
|
||||||
maxHeight?: string | number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A general-purpose wrapper for displaying source code.
|
* A component for displaying source code.
|
||||||
*
|
*
|
||||||
* It supports both block-level (multi-line) and inline (snippet) display modes,
|
* When used inside a `CodeBlock`, it renders a block-level `<code>` element
|
||||||
* with features like line numbering, syntax highlighting containers, and copy-to-clipboard functionality.
|
* with support for line numbering.
|
||||||
|
* When used standalone, it renders an inline snippet that is clickable to copy.
|
||||||
*/
|
*/
|
||||||
export const Code = memo(function Code({
|
export const Code = memo(function Code({
|
||||||
children,
|
children,
|
||||||
variant = 'block',
|
|
||||||
label,
|
|
||||||
showLineNumbers = true,
|
|
||||||
copyValue,
|
copyValue,
|
||||||
maxHeight,
|
|
||||||
className,
|
className,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: CodeProps) {
|
}: CodeProps) {
|
||||||
|
const context = useCodeContext();
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const codeRef = useRef<HTMLElement>(null);
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
@@ -97,105 +67,61 @@ export const Code = memo(function Code({
|
|||||||
}
|
}
|
||||||
}, [isCopied]);
|
}, [isCopied]);
|
||||||
|
|
||||||
// Inline Variant
|
|
||||||
const { pressProps } = usePress({
|
const { pressProps } = usePress({
|
||||||
onPress: onCopyPress,
|
onPress: onCopyPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (variant === 'inline') {
|
if (context?.isInCodeBlock) {
|
||||||
|
const { showLineNumbers } = context;
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
{...pressProps}
|
|
||||||
ref={codeRef}
|
ref={codeRef}
|
||||||
aria-label="Copy code snippet"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
|
'block min-w-full font-mono whitespace-pre',
|
||||||
'bg-[var(--rb-muted-base)]/50 text-[var(--rb-text)] border border-[var(--rb-outline)]/20',
|
showLineNumbers && [
|
||||||
'cursor-pointer transition-all duration-200',
|
'[counter-reset:line]',
|
||||||
'hover:bg-[var(--rb-muted-base)]/80',
|
'[&>*]:relative [&>*]:block [&>*]:pl-10',
|
||||||
'active:scale-95',
|
'[&>*]:[counter-increment:line]',
|
||||||
isCopied && 'ring-1 ring-[var(--rb-secondary-base)]',
|
'[&>*]:before:content-[counter(line)]',
|
||||||
|
'[&>*]:before:absolute [&>*]:before:left-0',
|
||||||
|
'[&>*]:before:inline-block',
|
||||||
|
'[&>*]:before:w-8',
|
||||||
|
'[&>*]:before:text-right',
|
||||||
|
'[&>*]:before:text-[var(--rb-muted-text)]/50',
|
||||||
|
'[&>*]:before:select-none',
|
||||||
|
'[&>*]:before:border-r',
|
||||||
|
'[&>*]:before:border-[var(--rb-outline)]/10',
|
||||||
|
'[&>*]:before:pr-3',
|
||||||
|
'[&>*]:before:font-mono [&>*]:before:text-[10px]',
|
||||||
|
],
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block Variant
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<code
|
||||||
border
|
{...pressProps}
|
||||||
bg="base"
|
ref={codeRef}
|
||||||
className={cn('relative group overflow-hidden', className)}
|
aria-label="Copy code snippet"
|
||||||
|
className={cn(
|
||||||
|
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
|
||||||
|
'bg-[var(--rb-muted-base)]/50 text-[var(--rb-text)] border border-[var(--rb-outline)]/20',
|
||||||
|
'cursor-pointer transition-all duration-200',
|
||||||
|
'hover:bg-[var(--rb-muted-base)]/80',
|
||||||
|
'active:scale-95',
|
||||||
|
isCopied && 'ring-1 ring-[var(--rb-secondary-base)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
rounded="md"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{/* Header Controls */}
|
{children}
|
||||||
<div className="absolute top-0 left-0 right-0 h-0 z-10 pointer-events-none">
|
</code>
|
||||||
{label && (
|
|
||||||
<span className="absolute top-2 left-4 text-xs font-mono text-[var(--rb-muted-text)] select-none bg-[var(--rb-container-base)]/80 backdrop-blur-sm px-1.5 rounded pointer-events-auto">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
|
||||||
<Button
|
|
||||||
aria-label="Copy code"
|
|
||||||
className={cn(
|
|
||||||
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
|
|
||||||
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
|
||||||
)}
|
|
||||||
color="neutral"
|
|
||||||
size="s"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={onCopyPress}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<Check className="w-3.5 h-3.5 text-[var(--rb-secondary-text)]" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Code Area */}
|
|
||||||
<pre
|
|
||||||
className={cn(
|
|
||||||
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text',
|
|
||||||
label && 'pt-8' // Add padding if label is present to avoid overlap
|
|
||||||
)}
|
|
||||||
style={{ maxHeight: maxHeight }}
|
|
||||||
>
|
|
||||||
<code
|
|
||||||
ref={codeRef}
|
|
||||||
className={cn(
|
|
||||||
'block min-w-full',
|
|
||||||
showLineNumbers && [
|
|
||||||
'[counter-reset:line] pl-10',
|
|
||||||
'[&>*]:relative [&>*]:block',
|
|
||||||
'[&>*]:[counter-increment:line]',
|
|
||||||
'[&>*]:before:content-[counter(line)]',
|
|
||||||
'[&>*]:before:absolute [&>*]:before:left-[-2.5rem]',
|
|
||||||
'[&>*]:before:inline-block',
|
|
||||||
'[&>*]:before:w-8',
|
|
||||||
'[&>*]:before:text-right',
|
|
||||||
'[&>*]:before:text-[var(--rb-muted-text)]/50',
|
|
||||||
'[&>*]:before:select-none',
|
|
||||||
'[&>*]:before:border-r',
|
|
||||||
'[&>*]:before:border-[var(--rb-outline)]/10',
|
|
||||||
'[&>*]:before:pr-3',
|
|
||||||
'[&>*]:before:font-mono [&>*]:before:text-[10px]', // Use mono for numbers too
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { cn } from '@ermnvldmr/stl';
|
||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import React, { memo, useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '../../Button/Button';
|
||||||
|
import { Container } from '../../Container/Container';
|
||||||
|
import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/CodeContext';
|
||||||
|
|
||||||
|
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the CodeBlock component.
|
||||||
|
*/
|
||||||
|
export interface CodeBlockProps extends ClassNameProps, TestIdProps {
|
||||||
|
/** The content to display, typically a Code component. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional label text (e.g., filename "index.ts", language "bash")
|
||||||
|
* displayed in the top-left corner.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to render line number gutters.
|
||||||
|
* Requires children (Code) to be rendered as block-level elements.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw string value to copy to clipboard.
|
||||||
|
* If omitted, the component will attempt to extract text from its content.
|
||||||
|
*/
|
||||||
|
copyValue?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum height of the code block before vertical scrolling is enabled.
|
||||||
|
*/
|
||||||
|
maxHeight?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container for multi-line source code display.
|
||||||
|
*
|
||||||
|
* It provides a framed container with optional label, copy-to-clipboard button,
|
||||||
|
* and line numbering support for its children.
|
||||||
|
*/
|
||||||
|
export const CodeBlock = memo(function CodeBlock({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
showLineNumbers = true,
|
||||||
|
copyValue,
|
||||||
|
maxHeight,
|
||||||
|
className,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: CodeBlockProps) {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
const preRef = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
let textToCopy = copyValue;
|
||||||
|
if (!textToCopy && preRef.current) {
|
||||||
|
textToCopy = preRef.current.innerText;
|
||||||
|
if (!textToCopy) {
|
||||||
|
textToCopy = preRef.current.textContent ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textToCopy) {
|
||||||
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
|
setIsCopied(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy code:', err);
|
||||||
|
}
|
||||||
|
}, [copyValue]);
|
||||||
|
|
||||||
|
const onCopyPress = useCallback(() => {
|
||||||
|
void handleCopy();
|
||||||
|
}, [handleCopy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCopied) {
|
||||||
|
const timer = setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isCopied]);
|
||||||
|
|
||||||
|
const contextValue = useMemo<CodeContextValue>(
|
||||||
|
() => ({
|
||||||
|
isInCodeBlock: true,
|
||||||
|
showLineNumbers,
|
||||||
|
}),
|
||||||
|
[showLineNumbers]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
border
|
||||||
|
bg="base"
|
||||||
|
className={cn('relative group overflow-hidden', className)}
|
||||||
|
data-testid={testId}
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
{/* Header Controls */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0 z-10 pointer-events-none">
|
||||||
|
{label && (
|
||||||
|
<span className="absolute top-2 left-4 text-xs font-mono text-[var(--rb-muted-text)] select-none bg-[var(--rb-container-base)]/80 backdrop-blur-sm px-1.5 rounded pointer-events-auto">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||||
|
<Button
|
||||||
|
aria-label="Copy code"
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
|
||||||
|
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||||
|
)}
|
||||||
|
color="neutral"
|
||||||
|
size="s"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={onCopyPress}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-[var(--rb-secondary-text)]" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Area */}
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
className={cn(
|
||||||
|
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
|
||||||
|
label && 'pt-8'
|
||||||
|
)}
|
||||||
|
style={{ maxHeight: maxHeight }}
|
||||||
|
>
|
||||||
|
<CodeContext.Provider value={contextValue}>{children}</CodeContext.Provider>
|
||||||
|
</pre>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value for the Code context.
|
||||||
|
*/
|
||||||
|
export interface CodeContextValue {
|
||||||
|
/** Whether the code is rendered inside a CodeBlock. */
|
||||||
|
isInCodeBlock: boolean;
|
||||||
|
/** Whether to show line numbers in the block. */
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for sharing code-related state between CodeBlock and Code components.
|
||||||
|
*/
|
||||||
|
export const CodeContext = createContext<CodeContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the Code context.
|
||||||
|
*
|
||||||
|
* @returns The current code context value or null if not within a CodeBlock.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const context = useCodeContext();
|
||||||
|
* if (context?.isInCodeBlock) {
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCodeContext(): CodeContextValue | null {
|
||||||
|
return useContext(CodeContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './Code';
|
||||||
|
export * from './CodeBlock/CodeBlock';
|
||||||
@@ -10,7 +10,7 @@ export * from './List/List';
|
|||||||
export * from './Markdown/Markdown';
|
export * from './Markdown/Markdown';
|
||||||
export * from './Paragraph/Paragraph';
|
export * from './Paragraph/Paragraph';
|
||||||
export * from './Button/Button';
|
export * from './Button/Button';
|
||||||
export * from './Code/Code';
|
export * from './Code';
|
||||||
export * from './Blockquote';
|
export * from './Blockquote';
|
||||||
export * from './Stub/Stub';
|
export * from './Stub/Stub';
|
||||||
export * from './Image/Image';
|
export * from './Image/Image';
|
||||||
@@ -29,7 +29,7 @@ export type { TextProps, TextType, TextSize, TextColor } from './Text/Text';
|
|||||||
export type { HeaderProps } from './Header/Header';
|
export type { HeaderProps } from './Header/Header';
|
||||||
export type { ParagraphProps } from './Paragraph/Paragraph';
|
export type { ParagraphProps } from './Paragraph/Paragraph';
|
||||||
export type { ButtonProps, ButtonVariant, ButtonColor, ButtonSize } from './Button/Button';
|
export type { ButtonProps, ButtonVariant, ButtonColor, ButtonSize } from './Button/Button';
|
||||||
export type { CodeProps } from './Code/Code';
|
export type { CodeProps, CodeBlockProps } from './Code';
|
||||||
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
||||||
export type { StubProps } from './Stub/Stub';
|
export type { StubProps } from './Stub/Stub';
|
||||||
export type { ImageProps } from './Image/Image';
|
export type { ImageProps } from './Image/Image';
|
||||||
|
|||||||
Reference in new issue
Block a user