diff --git a/packages/ui/src/components/Code/Code.stories.tsx b/packages/ui/src/components/Code/Code.stories.tsx index a2d0774..2522e0f 100644 --- a/packages/ui/src/components/Code/Code.stories.tsx +++ b/packages/ui/src/components/Code/Code.stories.tsx @@ -1,31 +1,20 @@ +import React from 'react'; + import { Code } from './Code'; +import { CodeBlock } from './CodeBlock/CodeBlock'; import type { Meta, StoryObj } from '@storybook/react'; -const meta: Meta = { +const meta: Meta = { title: 'Components/Code', - component: Code, tags: ['autodocs'], - argTypes: { - variant: { - control: 'radio', - options: ['block', 'inline'], - }, - showLineNumbers: { - control: 'boolean', - }, - label: { - control: 'text', - }, - }, }; export default meta; -type Story = StoryObj; // Helper to simulate highlighted lines const MockHighlightedCode = () => ( - <> + import React from 'react'; @@ -33,73 +22,85 @@ const MockHighlightedCode = () => ( export const MyComponent = () => { - - return <div>Hello World</div>; + + {' '}return <div>Hello World</div>; }; - + ); const LongHighlightedCode = () => ( - <> + {Array.from({ length: 50 }).map((_, i) => ( line_{i + 1}:{' '} const x = {Math.random()}; ))} - + ); -export const DefaultBlock: Story = { +export const DefaultBlock: StoryObj = { + render: (args) => ( + + + + ), args: { - children: , - variant: 'block', label: 'MyComponent.tsx', }, }; -export const WithoutLabel: Story = { +export const WithoutLabel: StoryObj = { + render: (args) => ( + + + + ), args: { - children: , - variant: 'block', showLineNumbers: true, }, }; -export const WithoutLineNumbers: Story = { +export const WithoutLineNumbers: StoryObj = { + render: (args) => ( + + + + ), args: { - children: , - variant: 'block', showLineNumbers: false, label: 'no-lines.js', }, }; -export const Inline: Story = { - args: { - children: 'npm install @ermnvldmr/ui', - variant: 'inline', - }, +export const Inline: StoryObj = { + render: (args) => npm install @ermnvldmr/ui, }; -export const LongWithScroll: Story = { +export const LongWithScroll: StoryObj = { + render: (args) => ( + + + + ), args: { - children: , - variant: 'block', label: 'long-file.ts', maxHeight: '300px', }, }; -export const PlainText: Story = { +export const PlainText: StoryObj = { + render: (args) => ( + + + {`const simple = "string content"; +console.log(simple);`} + + + ), args: { - children: `const simple = "string content"; -console.log(simple);`, - variant: 'block', 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, }, }; diff --git a/packages/ui/src/components/Code/Code.test.tsx b/packages/ui/src/components/Code/Code.test.tsx index dcebcd5..57cca2a 100644 --- a/packages/ui/src/components/Code/Code.test.tsx +++ b/packages/ui/src/components/Code/Code.test.tsx @@ -2,9 +2,10 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import React from 'react'; import { Code } from './Code'; +import { CodeBlock } from './CodeBlock/CodeBlock'; // Mock clipboard API -const mockWriteText = jest.fn(); +const mockWriteText = jest.fn().mockImplementation(() => Promise.resolve()); Object.assign(navigator, { clipboard: { writeText: mockWriteText, @@ -17,7 +18,7 @@ describe('Code Component', () => { }); it('renders inline code correctly', () => { - render(const x = 1;); + render(const x = 1;); const codeElement = screen.getByText('const x = 1;'); expect(codeElement.tagName).toBe('CODE'); expect(codeElement).toHaveClass('rounded-sm'); @@ -25,9 +26,11 @@ describe('Code Component', () => { it('renders block code correctly', () => { render( - - console.log("hello"); - + + + console.log("hello"); + + ); // Should verify it's inside a pre tag const preElement = screen.getByText('console.log("hello");').closest('pre'); @@ -37,42 +40,43 @@ describe('Code Component', () => { expect(screen.getByText('test.ts')).toBeInTheDocument(); }); - it('copies content on click in inline mode', () => { - render( - - Display Text - - ); + it('copies content on click in inline mode', async () => { + render(Display Text); const codeElement = screen.getByText('Display Text'); - act(() => { + await act(async () => { fireEvent.click(codeElement); + await Promise.resolve(); }); expect(mockWriteText).toHaveBeenCalledWith('copied!'); }); - it('copies content via button in block mode', () => { + it('copies content via button in block mode', async () => { render( - - some code - + + + some code + + ); const copyButton = screen.getByLabelText('Copy code'); - act(() => { + await act(async () => { fireEvent.click(copyButton); + await Promise.resolve(); }); expect(mockWriteText).toHaveBeenCalledWith('block copy'); }); - it('extracts text from children if copyValue is not provided', () => { - render(extracted); + it('extracts text from children if copyValue is not provided', async () => { + render(extracted); const codeElement = screen.getByText('extracted'); - act(() => { + await act(async () => { fireEvent.click(codeElement); + await Promise.resolve(); }); expect(mockWriteText).toHaveBeenCalledWith('extracted'); diff --git a/packages/ui/src/components/Code/Code.tsx b/packages/ui/src/components/Code/Code.tsx index 89bdd55..1696aef 100644 --- a/packages/ui/src/components/Code/Code.tsx +++ b/packages/ui/src/components/Code/Code.tsx @@ -1,69 +1,39 @@ import { cn } from '@ermnvldmr/stl'; -import { Check, Copy } from 'lucide-react'; import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { usePress } from 'react-aria'; -import { Button } from '../Button/Button'; -import { Container } from '../Container/Container'; +import { useCodeContext } from './contexts/CodeContext/CodeContext'; import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl'; /** - * + * Props for the Code component. */ export interface CodeProps extends ClassNameProps, TestIdProps { /** The content to display. Can be a string or pre-highlighted HTML elements. */ children: React.ReactNode; /** - * Display style of the code. - * - 'block': Multi-line container with optional scroll, line numbers, and header controls. - * - '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`. + * Raw string value to copy to clipboard (only used in inline mode). + * If omitted in inline mode, the component will attempt to extract text from `children`. */ 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, - * with features like line numbering, syntax highlighting containers, and copy-to-clipboard functionality. + * When used inside a `CodeBlock`, it renders a block-level `` element + * with support for line numbering. + * When used standalone, it renders an inline snippet that is clickable to copy. */ export const Code = memo(function Code({ children, - variant = 'block', - label, - showLineNumbers = true, copyValue, - maxHeight, className, 'data-testid': testId, }: CodeProps) { + const context = useCodeContext(); const [isCopied, setIsCopied] = useState(false); const codeRef = useRef(null); @@ -97,105 +67,61 @@ export const Code = memo(function Code({ } }, [isCopied]); - // Inline Variant const { pressProps } = usePress({ onPress: onCopyPress, }); - if (variant === 'inline') { + if (context?.isInCodeBlock) { + const { showLineNumbers } = context; return ( *]:relative [&>*]:block [&>*]:pl-10', + '[&>*]:[counter-increment:line]', + '[&>*]: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 )} data-testid={testId} - role="button" - tabIndex={0} > {children} ); } - // Block Variant return ( - - {/* Header Controls */} -
- {label && ( - - {label} - - )} -
- -
-
- - {/* Code Area */} -
-        *]: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}
-        
-      
-
+ {children} +
); -}); +}); \ No newline at end of file diff --git a/packages/ui/src/components/Code/CodeBlock/CodeBlock.tsx b/packages/ui/src/components/Code/CodeBlock/CodeBlock.tsx new file mode 100644 index 0000000..c40bcbf --- /dev/null +++ b/packages/ui/src/components/Code/CodeBlock/CodeBlock.tsx @@ -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(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( + () => ({ + isInCodeBlock: true, + showLineNumbers, + }), + [showLineNumbers] + ); + + return ( + + {/* Header Controls */} +
+ {label && ( + + {label} + + )} +
+ +
+
+ + {/* Code Area */} +
+        {children}
+      
+
+ ); +}); diff --git a/packages/ui/src/components/Code/contexts/CodeContext/CodeContext.ts b/packages/ui/src/components/Code/contexts/CodeContext/CodeContext.ts new file mode 100644 index 0000000..de16cb4 --- /dev/null +++ b/packages/ui/src/components/Code/contexts/CodeContext/CodeContext.ts @@ -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(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); +} diff --git a/packages/ui/src/components/Code/index.ts b/packages/ui/src/components/Code/index.ts new file mode 100644 index 0000000..14ae3ac --- /dev/null +++ b/packages/ui/src/components/Code/index.ts @@ -0,0 +1,2 @@ +export * from './Code'; +export * from './CodeBlock/CodeBlock'; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index f77df9f..0aebddc 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -10,7 +10,7 @@ export * from './List/List'; export * from './Markdown/Markdown'; export * from './Paragraph/Paragraph'; export * from './Button/Button'; -export * from './Code/Code'; +export * from './Code'; export * from './Blockquote'; export * from './Stub/Stub'; 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 { ParagraphProps } from './Paragraph/Paragraph'; 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 { StubProps } from './Stub/Stub'; export type { ImageProps } from './Image/Image';