fix: Make Code more like pre/code elements

This commit is contained in:
deytenit committed 2026-02-10 07:26:53 +03:00
1 parent 42a0b71a8f
commit cb956be216
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 &apos;react&apos;; import React from &apos;react&apos;;
</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 = () =&gt; &#123; export const MyComponent = () =&gt; &#123;
</span> </span>
<span className="block text-[var(--rb-text)] pl-4"> <span className="block text-[var(--rb-text)]">
return &lt;div&gt;Hello World&lt;/div&gt;; {' '}return &lt;div&gt;Hello World&lt;/div&gt;;
</span> </span>
<span className="block text-[var(--rb-secondary-text)]">&#125;;</span> <span className="block text-[var(--rb-secondary-text)]">&#125;;</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,
}, },
}; };
+24 -20
View File
@@ -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(&quot;hello&quot;);</span> <Code>
</Code> <span>console.log(&quot;hello&quot;);</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');
+45 -119
View File
@@ -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);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './Code';
export * from './CodeBlock/CodeBlock';
+2 -2
View File
@@ -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';