From f3866b1d0af6a3c708e1b5e6b277f87b4727aab3 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Sun, 5 Oct 2025 17:30:33 +0530 Subject: [PATCH 1/6] wip: code block --- apps/www/package.json | 1 + apps/www/src/app/test/page.jsx | 258 ++++++++++++++++ .../components/playground/code-block-demo.tsx | 86 ++++++ .../docs/components/code-block/demo.ts | 239 +++++++++++++++ .../docs/components/code-block/index.js | 1 + .../docs/components/code-block/index.mdx | 165 ++++++++++ .../docs/components/code-block/props.ts | 110 +++++++ .../code-block/__tests__/code-block.test.tsx | 68 ++++ .../components/code-block/code-block-code.tsx | 72 +++++ .../code-block/code-block-content.tsx | 23 ++ .../code-block/code-block-copy-button.tsx | 31 ++ .../code-block/code-block-header.tsx | 70 +++++ .../code-block/code-block-language-select.tsx | 52 ++++ .../components/code-block/code-block-root.tsx | 73 +++++ .../code-block/code-block.module.css | 290 ++++++++++++++++++ .../components/code-block/code-block.tsx | 27 ++ .../raystack/components/code-block/index.tsx | 1 + packages/raystack/index.tsx | 1 + packages/raystack/package.json | 1 + pnpm-lock.yaml | 12 + 20 files changed, 1581 insertions(+) create mode 100644 apps/www/src/app/test/page.jsx create mode 100644 apps/www/src/components/playground/code-block-demo.tsx create mode 100644 apps/www/src/content/docs/components/code-block/demo.ts create mode 100644 apps/www/src/content/docs/components/code-block/index.js create mode 100644 apps/www/src/content/docs/components/code-block/index.mdx create mode 100644 apps/www/src/content/docs/components/code-block/props.ts create mode 100644 packages/raystack/components/code-block/__tests__/code-block.test.tsx create mode 100644 packages/raystack/components/code-block/code-block-code.tsx create mode 100644 packages/raystack/components/code-block/code-block-content.tsx create mode 100644 packages/raystack/components/code-block/code-block-copy-button.tsx create mode 100644 packages/raystack/components/code-block/code-block-header.tsx create mode 100644 packages/raystack/components/code-block/code-block-language-select.tsx create mode 100644 packages/raystack/components/code-block/code-block-root.tsx create mode 100644 packages/raystack/components/code-block/code-block.module.css create mode 100644 packages/raystack/components/code-block/code-block.tsx create mode 100644 packages/raystack/components/code-block/index.tsx diff --git a/apps/www/package.json b/apps/www/package.json index 316a7686..fae465b8 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -22,6 +22,7 @@ "next": "14.2.5", "next-themes": "^0.4.4", "prettier": "^2.8.8", + "prism-react-renderer": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-live": "^4.1.8", diff --git a/apps/www/src/app/test/page.jsx b/apps/www/src/app/test/page.jsx new file mode 100644 index 00000000..e2f801ef --- /dev/null +++ b/apps/www/src/app/test/page.jsx @@ -0,0 +1,258 @@ +'use client'; +// @ts-nocheck +/* tslint:disable */ +import { CodeBlock } from '@raystack/apsara'; + +export default function Page() { + return ( +
+

+ CodeBlock Examples +

+ + + code + + + + + + code + + + {/* Basic usage with single language */} +
+

+ Basic Usage +

+ + + Simple JavaScript function + + + + {`function greetUser(name) { + const message = \`Hello, \${name}!\`; + console.log(message); + return message; +} + +const user = "World"; +const greeting = greetUser(user); +console.log(greeting); // Output: Hello, World!`} + + + +
+ + {/* Multi-language example */} +
+

+ Multi-Language Example +

+ + + Hello World in Multiple Languages + + + + + + JavaScript + + + TypeScript + + + Python + + + Java + + + + + + + + + + {`function greetUser(name) { + const message = \`Hello, \${name}!\`; + console.log(message); + return message; +} + +const user = "World"; +const greeting = greetUser(user);`} + + + + {`interface User { + name: string; + age: number; + email: string; +} + +function greetUser(user: User): string { + const message = \`Hello, \${user.name}!\`; + console.log(message); + return message; +} + +const user: User = { + name: "World", + age: 25, + email: "world@example.com" +}; + +const greeting = greetUser(user);`} + + + + {`class User: + def __init__(self, name: str, age: int, email: str): + self.name = name + self.age = age + self.email = email + +def greet_user(user: User) -> str: + message = f"Hello, {user.name}!" + print(message) + return message + +user = User("World", 25, "world@example.com") +greeting = greet_user(user)`} + + + + {`public class User { + private String name; + private int age; + private String email; + + public User(String name, int age, String email) { + this.name = name; + this.age = age; + this.email = email; + } + + public String getName() { return name; } + public int getAge() { return age; } + public String getEmail() { return email; } +} + +public class Greeter { + public static String greetUser(User user) { + String message = "Hello, " + user.getName() + "!"; + System.out.println(message); + return message; + } + + public static void main(String[] args) { + User user = new User("World", 25, "world@example.com"); + String greeting = greetUser(user); + } +}`} + + + +
+ + {/* Configuration options */} +
+

+ With Configuration Options +

+ + + CSS Styles without Line Numbers + + + + {`.user-card { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 12px; + padding: 24px; + color: white; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; +} + +.user-card:hover { + transform: translateY(-4px); +} + +.user-name { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 8px; +} + +.user-details { + opacity: 0.9; + font-size: 0.9rem; +}`} + + + +
+ + {/* Minimal example */} +
+

+ Minimal Example +

+ + + + {` + + + + + User Greeting + + +
+

Hello, World!

+
+

Age: 25

+

Email: world@example.com

+
+
+ +`} +
+
+
+
+
+ ); +} diff --git a/apps/www/src/components/playground/code-block-demo.tsx b/apps/www/src/components/playground/code-block-demo.tsx new file mode 100644 index 00000000..ecbe2590 --- /dev/null +++ b/apps/www/src/components/playground/code-block-demo.tsx @@ -0,0 +1,86 @@ +import { CodeBlock } from '@raystack/raystack'; + +export function CodeBlockDemo() { + return ( +
+

CodeBlock Component Examples

+ +

Basic Usage (Content Only)

+ + + {`function hello() { + console.log('Hello, world!'); +}`} + + + +

With Header

+ + + + {`function calculateSum(a, b) { + return a + b; +}`} + + + +

Python Example

+ + + + {`def fibonacci(n): + if n <= 1: + return n + return fibonacci(n-1) + fibonacci(n-2)`} + + + +

Without Line Numbers

+ + + {`const data = { + name: 'John', + age: 30, + city: 'New York' +};`} + + + +

With Max Height

+ + + + {`// This is a very long code block that should be scrollable +function processLargeDataset(data) { + const results = []; + + for (let i = 0; i < data.length; i++) { + const item = data[i]; + + // Process each item + if (item.type === 'user') { + results.push({ + id: item.id, + name: item.name, + email: item.email, + createdAt: new Date(item.createdAt), + updatedAt: new Date(item.updatedAt) + }); + } else if (item.type === 'product') { + results.push({ + id: item.id, + title: item.title, + price: item.price, + category: item.category, + inStock: item.inStock + }); + } + } + + return results; +}`} + + +
+ ); +} diff --git a/apps/www/src/content/docs/components/code-block/demo.ts b/apps/www/src/content/docs/components/code-block/demo.ts new file mode 100644 index 00000000..8e3d8efd --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/demo.ts @@ -0,0 +1,239 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: Record) => { + const { children, ...rest } = props; + return `${children}`; +}; + +export const playground = { + type: 'playground', + controls: { + language: { + type: 'select', + options: [ + 'javascript', + 'typescript', + 'python', + 'java', + 'css', + 'html', + 'json' + ], + defaultValue: 'javascript' + }, + showLineNumbers: { + type: 'checkbox', + defaultValue: true + }, + showCopyButton: { + type: 'checkbox', + defaultValue: true + }, + maxHeight: { + type: 'text', + defaultValue: '' + } + }, + code: `function greetUser(name) { + const message = \`Hello, \${name}!\`; + console.log(message); + return message; +} + +const user = "World"; +const greeting = greetUser(user);` +}; + +export const basicDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; + +function MyComponent() { + const code = \`function hello() { + console.log('Hello, world!'); +}\`; + + return ( + + {code} + + ); +}` +}; + +export const withoutHeaderDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; + +function MyComponent() { + const code = \`function hello() { + console.log('Hello, world!'); +}\`; + + return ( + + {code} + + ); +}` +}; + +export const customLanguageDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; + +function MyComponent() { + const pythonCode = \`def fibonacci(n): + if n <= 1: + return n + return fibonacci(n-1) + fibonacci(n-2) + +print(fibonacci(10))\`; + + return ( + + {pythonCode} + + ); +}` +}; + +export const noLineNumbersDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; + +function MyComponent() { + const code = \`function hello() { + console.log('Hello, world!'); +}\`; + + return ( + + {code} + + ); +}` +}; + +export const maxHeightDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; + +function MyComponent() { + const longCode = \`// This is a very long code example +function processData(data) { + const result = []; + + for (let i = 0; i < data.length; i++) { + const item = data[i]; + + if (item.type === 'user') { + result.push({ + id: item.id, + name: item.name, + email: item.email, + createdAt: new Date(item.createdAt), + updatedAt: new Date(item.updatedAt) + }); + } else if (item.type === 'admin') { + result.push({ + id: item.id, + name: item.name, + role: item.role, + permissions: item.permissions, + createdAt: new Date(item.createdAt) + }); + } + } + + return result; +} + +export default processData;\`; + + return ( + + {longCode} + + ); +}` +}; + +export const compositePatternDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; + +function MyComponent() { + const code = \`function hello() { + console.log('Hello, world!'); +}\`; + + return ( +
+ {/* Using Root component */} + + {code} + + + {/* Using Content component */} + + {code} + + + {/* Using Header component */} + +
+ ); +}` +}; + +export const languageSwitcherDemo = { + type: 'code', + code: `import { CodeBlockComponent } from '@raystack/apsara'; +import { useState } from 'react'; + +function MyComponent() { + const [currentLanguage, setCurrentLanguage] = useState('javascript'); + + const codeExamples = { + javascript: \`function greet(name) { + return \`Hello, \${name}!\`; +}\`, + typescript: \`function greet(name: string): string { + return \`Hello, \${name}!\`; +}\`, + python: \`def greet(name): + return f"Hello, {name}!"\`, + java: \`public String greet(String name) { + return "Hello, " + name + "!"; +}\` + }; + + return ( +
+ + + {codeExamples[currentLanguage]} + +
+ ); +}` +}; diff --git a/apps/www/src/content/docs/components/code-block/index.js b/apps/www/src/content/docs/components/code-block/index.js new file mode 100644 index 00000000..445d8aa7 --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/index.js @@ -0,0 +1 @@ +export { default } from './index.mdx'; diff --git a/apps/www/src/content/docs/components/code-block/index.mdx b/apps/www/src/content/docs/components/code-block/index.mdx new file mode 100644 index 00000000..46c50f30 --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/index.mdx @@ -0,0 +1,165 @@ +--- +title: Code Block +description: A composite component for displaying code with syntax highlighting, line numbers, and copy functionality using the composite pattern. +--- + +import { + playground, + basicDemo, + withoutHeaderDemo, + customLanguageDemo, + noLineNumbersDemo, + maxHeightDemo, + compositePatternDemo, + languageSwitcherDemo, +} from "./demo.ts"; + +{/* */} + +## Usage + +```tsx +import { CodeBlockComponent } from '@raystack/apsara' +``` + +## Component Props + +### CodeBlockComponent.Root Props + + + +### CodeBlockComponent.Header Props + + + +### CodeBlockComponent.Content Props + + + +## Examples + +### Basic Usage + + + +The most basic usage using `CodeBlockComponent.Root` for displaying code content. + +### Without Header + + + +Using `CodeBlockComponent.Root` displays only the code content with a floating copy button. + +### Custom Language + + + +Specify different programming languages for proper syntax highlighting. Supported languages include JavaScript, TypeScript, Python, Java, CSS, HTML, and more. + +### No Line Numbers + + + +Disable line numbers by setting `showLineNumbers={false}`. + +### Max Height with Scrolling + + + +Set a maximum height to create scrollable code blocks for long code snippets. + +### Composite Pattern + + + +The CodeBlock component follows a composite pattern, allowing you to use individual parts: + +- `CodeBlockComponent.Root` - Core code rendering with floating copy button +- `CodeBlockComponent.Header` - Header with label, language switcher, and copy button +- `CodeBlockComponent.Content` - Content-only version with optional copy button + +### Language Switcher + + + +Use the `CodeBlockComponent.Header` with `onLanguageChange` callback to implement dynamic language switching with custom language options. + +## Composite Pattern Usage + +The CodeBlock component is designed to be used exclusively through the composite pattern: + +```tsx +import { CodeBlockComponent } from '@raystack/apsara'; + +// Root component - displays code with floating copy button + + {code} + + +// Header component - displays label, language switcher, and copy button + + +// Content component - displays code with optional copy button + + {code} + +``` + +## Features + +### Syntax Highlighting + +The CodeBlock component uses Prism.js for syntax highlighting, supporting a wide range of programming languages with the VS Dark theme. + +### Copy Functionality + +- **Header Mode**: Copy button appears in the header toolbar (`CodeBlockComponent.Header`) +- **Floating Mode**: Copy button appears as a floating overlay (`CodeBlockComponent.Root`) +- **Content Mode**: Optional copy button (`CodeBlockComponent.Content`) +- **Visual Feedback**: Button shows a checkmark icon when copy is successful +- **Auto Reset**: Returns to copy icon after 1 second + +### Line Numbers + +Line numbers are displayed by default and can be toggled off. They help with code navigation and referencing specific lines. + +### Language Switcher + +The header includes a dropdown to switch between different programming languages, useful for displaying the same code in multiple languages. + +### Responsive Design + +The component is fully responsive and adapts to different screen sizes: +- Header controls stack vertically on mobile +- Copy button remains accessible +- Code content scrolls horizontally when needed + +## Accessibility + +- **Keyboard Navigation**: All interactive elements are keyboard accessible +- **Screen Reader Support**: Proper ARIA labels and roles +- **Focus Management**: Clear focus indicators +- **Color Contrast**: Meets WCAG guidelines for text contrast + +## Best Practices + +1. **Use Appropriate Languages**: Always specify the correct language for proper syntax highlighting +2. **Consider Content Length**: Use `maxHeight` for very long code snippets +3. **Provide Context**: Use meaningful `label` values in headers +4. **Test Copy Functionality**: Ensure the code content is properly formatted for copying +5. **Mobile Considerations**: Test on mobile devices to ensure readability +6. **Composite Pattern**: Always use the composite pattern - no generic `CodeBlock` component exists + +## Related Components + +- [Copy Button](/docs/components/copy-button) - Standalone copy functionality +- [Text](/docs/components/text) - For displaying regular text content +- [Container](/docs/components/container) - For layout and spacing diff --git a/apps/www/src/content/docs/components/code-block/props.ts b/apps/www/src/content/docs/components/code-block/props.ts new file mode 100644 index 00000000..7e49cbfc --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/props.ts @@ -0,0 +1,110 @@ +export type CodeBlockRootProps = { + /** + * The code content to display + */ + children: React.ReactNode; + + /** + * Programming language for syntax highlighting + * @defaultValue "javascript" + */ + language?: string; + + /** + * Whether to show line numbers + * @defaultValue true + */ + showLineNumbers?: boolean; + + /** + * Maximum height of the code block + */ + maxHeight?: string | number; + + /** + * Additional CSS class name + */ + className?: string; +}; + +export type CodeBlockHeaderProps = { + /** + * Label text displayed in the header + * @defaultValue "Code" + */ + label?: string; + + /** + * Current programming language + * @defaultValue "javascript" + */ + language?: string; + + /** + * Available languages for the language switcher + * @defaultValue ["javascript", "typescript", "python", "java", "css", "html"] + */ + availableLanguages?: string[]; + + /** + * Callback when language is changed + */ + onLanguageChange?: (language: string) => void; + + /** + * The code content to copy + */ + codeContent?: string; + + /** + * Whether to show the line wrap toggle button + * @defaultValue true + */ + showLineWrapToggle?: boolean; + + /** + * Whether to show the copy button + * @defaultValue true + */ + showCopyButton?: boolean; + + /** + * Additional CSS class name + */ + className?: string; +}; + +export type CodeBlockContentProps = { + /** + * The code content to display + */ + children: React.ReactNode; + + /** + * Programming language for syntax highlighting + * @defaultValue "javascript" + */ + language?: string; + + /** + * Whether to show line numbers + * @defaultValue true + */ + showLineNumbers?: boolean; + + /** + * Maximum height of the code block + */ + maxHeight?: string | number; + + /** + * Whether to show the copy button + * @defaultValue true + */ + showCopyButton?: boolean; + + /** + * Additional CSS class name + */ + className?: string; +}; diff --git a/packages/raystack/components/code-block/__tests__/code-block.test.tsx b/packages/raystack/components/code-block/__tests__/code-block.test.tsx new file mode 100644 index 00000000..48252f62 --- /dev/null +++ b/packages/raystack/components/code-block/__tests__/code-block.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { CodeBlock } from '../code-block'; + +describe('CodeBlock', () => { + it('renders with content only', () => { + render( + + + {`function hello() { + console.log('Hello, world!'); +}`} + + + ); + + expect(screen.getByText(/function hello/)).toBeInTheDocument(); + }); + + it('renders with header and content', () => { + render( + + + JavaScript + + + {`function hello() { + console.log('Hello, world!'); +}`} + + + ); + + expect(screen.getByText('JavaScript')).toBeInTheDocument(); + expect(screen.getByText(/function hello/)).toBeInTheDocument(); + }); + + it('renders without line numbers when showLineNumbers is false', () => { + render( + + + {`function hello() { + console.log('Hello, world!'); +}`} + + + ); + + // Line numbers should not be present + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('renders with custom language', () => { + render( + + + + {`def hello(): + print("Hello, world!")`} + + + + ); + + expect(screen.getByText(/def hello/)).toBeInTheDocument(); + }); +}); diff --git a/packages/raystack/components/code-block/code-block-code.tsx b/packages/raystack/components/code-block/code-block-code.tsx new file mode 100644 index 00000000..82a2230c --- /dev/null +++ b/packages/raystack/components/code-block/code-block-code.tsx @@ -0,0 +1,72 @@ +import { Highlight, themes } from 'prism-react-renderer'; +import { Language } from 'prism-react-renderer'; +import { HTMLAttributes, forwardRef, useEffect } from 'react'; +import { useCodeBlockContext } from './code-block-root'; +import styles from './code-block.module.css'; + +export interface CodeBlockCodeProps extends HTMLAttributes { + children: string; + className?: string; +} + +const CodeBlockCode = forwardRef( + ({ children, className, ...props }, ref) => { + const { language, hideLineNumbers, setCode } = useCodeBlockContext(); + + useEffect(() => { + setCode(children); + }, [children, setCode]); + + return ( +
+ + {({ + className: highlightClassName, + style, + tokens, + getLineProps, + getTokenProps + }) => ( +
+              {tokens.map((line, i) => (
+                
+ {!hideLineNumbers && ( + {i + 1} + )} + + {line.map((token, key) => ( + + ))} + +
+ ))} +
+ )} +
+
+ ); + } +); + +CodeBlockCode.displayName = 'CodeBlockCode'; + +export { CodeBlockCode }; diff --git a/packages/raystack/components/code-block/code-block-content.tsx b/packages/raystack/components/code-block/code-block-content.tsx new file mode 100644 index 00000000..fd968a84 --- /dev/null +++ b/packages/raystack/components/code-block/code-block-content.tsx @@ -0,0 +1,23 @@ +import { HTMLAttributes, forwardRef } from 'react'; + +import styles from './code-block.module.css'; + +export interface CodeBlockContentProps extends HTMLAttributes {} + +const CodeBlockContent = forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +CodeBlockContent.displayName = 'CodeBlockContent'; + +export { CodeBlockContent }; diff --git a/packages/raystack/components/code-block/code-block-copy-button.tsx b/packages/raystack/components/code-block/code-block-copy-button.tsx new file mode 100644 index 00000000..52986216 --- /dev/null +++ b/packages/raystack/components/code-block/code-block-copy-button.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef } from 'react'; + +import { CopyButton } from '../copy-button'; +import { useCodeBlockContext } from './code-block-root'; +import styles from './code-block.module.css'; + +export interface CodeBlockCopyButtonProps + extends React.HTMLAttributes { + className?: string; +} + +const CodeBlockCopyButton = forwardRef< + HTMLDivElement, + CodeBlockCopyButtonProps +>(({ className, ...props }, ref) => { + const { code = '' } = useCodeBlockContext(); + + return ( +
+ +
+ ); +}); + +CodeBlockCopyButton.displayName = 'CodeBlockCopyButton'; + +export { CodeBlockCopyButton }; diff --git a/packages/raystack/components/code-block/code-block-header.tsx b/packages/raystack/components/code-block/code-block-header.tsx new file mode 100644 index 00000000..186a74cc --- /dev/null +++ b/packages/raystack/components/code-block/code-block-header.tsx @@ -0,0 +1,70 @@ +import React, { forwardRef } from 'react'; + +import { Text } from '../text'; +import styles from './code-block.module.css'; + +export interface CodeBlockHeaderProps + extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; +} + +export const CodeBlockHeader = forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+
{children}
+
+ ); + } +); + +CodeBlockHeader.displayName = 'CodeBlockHeader'; + +export interface CodeBlockLabelProps + extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; +} + +export const CodeBlockLabel = forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +CodeBlockLabel.displayName = 'CodeBlockLabel'; + +export interface CodeBlockActionProps + extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; +} + +export const CodeBlockAction = forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +CodeBlockAction.displayName = 'CodeBlockAction'; diff --git a/packages/raystack/components/code-block/code-block-language-select.tsx b/packages/raystack/components/code-block/code-block-language-select.tsx new file mode 100644 index 00000000..d8e735ba --- /dev/null +++ b/packages/raystack/components/code-block/code-block-language-select.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef } from 'react'; + +import { Language } from 'prism-react-renderer'; +import { Select } from '../select'; +import { useCodeBlockContext } from './code-block-root'; +import styles from './code-block.module.css'; + +export interface CodeBlockLanguageSelectProps + extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; +} + +export const CodeBlockLanguageSelect = forwardRef< + HTMLDivElement, + CodeBlockLanguageSelectProps +>(({ children, className, ...props }, ref) => { + const { selectedLanguage, setSelectedLanguage } = useCodeBlockContext(); + + const handleValueChange = (value: string) => { + setSelectedLanguage(value as Language); + }; + return ( + + ); +}); + +CodeBlockLanguageSelect.displayName = 'CodeBlockLanguageSelect'; + +export interface CodeBlockLanguageSelectTriggerProps + extends React.HTMLAttributes { + className?: string; +} + +export const CodeBlockLanguageSelectTrigger = forwardRef< + HTMLButtonElement, + CodeBlockLanguageSelectTriggerProps +>(({ className, ...props }, ref) => { + return ( + + + + ); +}); + +CodeBlockLanguageSelectTrigger.displayName = 'CodeBlockLanguageSelectTrigger'; diff --git a/packages/raystack/components/code-block/code-block-root.tsx b/packages/raystack/components/code-block/code-block-root.tsx new file mode 100644 index 00000000..20a8bc74 --- /dev/null +++ b/packages/raystack/components/code-block/code-block-root.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { + HTMLAttributes, + createContext, + forwardRef, + useContext, + useState +} from 'react'; +import styles from './code-block.module.css'; + +interface CodeBlockContextValue { + language: string; + hideLineNumbers: boolean; + code?: string; + setCode: (code?: string) => void; +} + +const CodeBlockContext = createContext( + undefined +); + +export const useCodeBlockContext = () => { + const context = useContext(CodeBlockContext); + if (!context) { + throw new Error('useCodeBlockContext must be used within a CodeBlock'); + } + return context; +}; + +export interface CodeBlockProps extends HTMLAttributes { + language: string; + hideLineNumbers?: boolean; + maxHeight?: string | number; + className?: string; +} + +export const CodeBlockRoot = forwardRef( + ( + { + children, + language, + hideLineNumbers = false, + maxHeight, + className, + ...props + }, + ref + ) => { + const [code, setCode] = useState(); + + return ( + +
+ {children} +
+
+ ); + } +); + +CodeBlockRoot.displayName = 'CodeBlock'; diff --git a/packages/raystack/components/code-block/code-block.module.css b/packages/raystack/components/code-block/code-block.module.css new file mode 100644 index 00000000..5ee2bd94 --- /dev/null +++ b/packages/raystack/components/code-block/code-block.module.css @@ -0,0 +1,290 @@ +/* Code Block Container */ +.container { + background-color: var(--rs-color-background-base-primary); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; + position: relative; +} + +/* Code Block Root */ +.root { + background-color: var(--rs-color-background-base-primary); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; + position: relative; +} + +.content { + position: relative; + overflow: hidden; +} + +.pre { + margin: 0; + padding: var(--rs-space-5); + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-regular); + background-color: var(--rs-color-background-base-primary); + overflow-x: auto; + overflow-y: auto; +} + +.line { + display: flex; + align-items: flex-start; + min-height: 1.5em; +} + +.lineNumber { + display: inline-block; + width: 2.5em; + padding-right: var(--rs-space-3); + text-align: right; + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-regular); + user-select: none; + flex-shrink: 0; +} + +.lineContent { + flex: 1; + min-width: 0; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--rs-space-3) var(--rs-space-5); + background-color: var(--rs-color-background-base-secondary); + border-bottom: 1px solid var(--rs-color-border-base-primary); + gap: var(--rs-space-4); +} + +.headerLeft { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: var(--rs-space-4); +} + +.headerRight { + display: flex; + align-items: center; + gap: var(--rs-space-4); +} + +.label { + font-family: var(--rs-font-body); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + color: var(--rs-color-foreground-base-primary); + margin: 0; +} + +.labelText { + font-family: var(--rs-font-body); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + color: var(--rs-color-foreground-base-primary); + margin: 0; +} + +/* Action */ +.action { + display: flex; + align-items: center; + gap: var(--rs-space-4); +} + +/* Language Selector */ +.languageSelector { + position: relative; +} + +.languageButton { + display: flex; + align-items: center; + gap: var(--rs-space-2); + padding: var(--rs-space-2) var(--rs-space-3); + background-color: transparent; + border: none; + border-radius: var(--rs-radius-2); + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.languageButton:hover { + background-color: var(--rs-color-background-base-primary-hover); +} + +.languageButton:focus-visible { + outline: 1px solid var(--rs-color-border-accent-emphasis); +} + +.languageText { + font-family: var(--rs-font-body); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + color: var(--rs-color-foreground-base-primary); + margin: 0; + text-transform: capitalize; +} + +.chevronIcon { + width: 16px; + height: 16px; + color: var(--rs-color-foreground-base-primary); + transition: transform 0.2s ease-in-out; +} + +.languageButton[aria-expanded="true"] .chevronIcon { + transform: rotate(180deg); +} + +.languageDropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: var(--rs-space-1); + background-color: var(--rs-color-background-base-primary); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-medium); + z-index: 10; + min-width: 120px; + overflow: hidden; +} + +.languageOption { + display: block; + width: 100%; + padding: var(--rs-space-2) var(--rs-space-3); + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.languageOption:hover { + background-color: var(--rs-color-background-base-primary-hover); +} + +.languageOptionActive { + background-color: var(--rs-color-background-accent-primary); +} + +.languageOptionText { + font-family: var(--rs-font-body); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + color: var(--rs-color-foreground-base-primary); + margin: 0; + text-transform: capitalize; +} + +.languageOptionActive .languageOptionText { + color: var(--rs-color-foreground-accent-primary); +} + +/* Line Wrap Button */ +.lineWrapButton { + padding: var(--rs-space-1); +} + +.lineWrapIcon { + width: 16px; + height: 16px; + color: var(--rs-color-foreground-base-primary); +} + +/* Language Select */ +.languageSelect { + min-width: 120px; +} + +.languageSelectTrigger { + /* Inherits styles from Select.Trigger */ +} + +.languageSelectContent { + /* Inherits styles from Select.Content */ +} + +.languageSelectItem { + /* Inherits styles from Select.Item */ +} + +/* Copy Button */ +.copyButton { + padding: var(--rs-space-1); +} + +.copyButtonContainer { + display: inline-flex; +} + +/* Code Content */ +.codeContent { + position: relative; +} + +/* Floating Copy Button */ +.floatingCopyButton { + position: absolute; + top: var(--rs-space-3); + right: var(--rs-space-3); + z-index: 5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: var(--rs-space-3); + } + + .headerRight { + width: 100%; + justify-content: space-between; + } + + .languageDropdown { + right: auto; + left: 0; + } +} + +/* Dark theme adjustments */ +@media (prefers-color-scheme: dark) { + .pre { + background-color: #1e1e1e; + } + + .root { + background-color: #1e1e1e; + border-color: #404040; + } + + .header { + background-color: #2d2d2d; + border-bottom-color: #404040; + } + + .languageDropdown { + background-color: #2d2d2d; + border-color: #404040; + } +} diff --git a/packages/raystack/components/code-block/code-block.tsx b/packages/raystack/components/code-block/code-block.tsx new file mode 100644 index 00000000..cc4053c0 --- /dev/null +++ b/packages/raystack/components/code-block/code-block.tsx @@ -0,0 +1,27 @@ +import { Select } from '../select'; +import { CodeBlockCode } from './code-block-code'; +import { CodeBlockContent } from './code-block-content'; +import { CodeBlockCopyButton } from './code-block-copy-button'; +import { + CodeBlockAction, + CodeBlockHeader, + CodeBlockLabel +} from './code-block-header'; +import { + CodeBlockLanguageSelect, + CodeBlockLanguageSelectTrigger +} from './code-block-language-select'; +import { CodeBlockRoot } from './code-block-root'; + +export const CodeBlock = Object.assign(CodeBlockRoot, { + Header: CodeBlockHeader, + Content: CodeBlockContent, + Label: CodeBlockLabel, + Action: CodeBlockAction, + LanguageSelect: CodeBlockLanguageSelect, + LanguageSelectTrigger: CodeBlockLanguageSelectTrigger, + LanguageSelectContent: Select.Content as typeof Select.Content, + LanguageSelectItem: Select.Item as typeof Select.Item, + CopyButton: CodeBlockCopyButton, + Code: CodeBlockCode +}); diff --git a/packages/raystack/components/code-block/index.tsx b/packages/raystack/components/code-block/index.tsx new file mode 100644 index 00000000..6bcab689 --- /dev/null +++ b/packages/raystack/components/code-block/index.tsx @@ -0,0 +1 @@ +export { CodeBlock } from './code-block'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 481bab61..7813f9f1 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -13,6 +13,7 @@ export { Calendar, DatePicker, RangePicker } from './components/calendar'; export { Callout } from './components/callout'; export { Checkbox } from './components/checkbox'; export { Chip } from './components/chip'; +export { CodeBlock } from './components/code-block'; export { Command } from './components/command'; export { Container } from './components/container'; export { CopyButton } from './components/copy-button'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 05620eb4..39c17bde 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -113,6 +113,7 @@ "cmdk": "^1.1.1", "color": "^5.0.0", "dayjs": "^1.11.11", + "prism-react-renderer": "^2.4.1", "radix-ui": "^1.4.2", "react-day-picker": "^9.6.7", "sonner": "^2.0.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 321a6487..1ee309b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: prettier: specifier: ^2.8.8 version: 2.8.8 + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -221,6 +224,9 @@ importers: dayjs: specifier: ^1.11.11 version: 1.11.11 + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@19.1.1) radix-ui: specifier: ^1.4.2 version: 1.4.2(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -20373,6 +20379,12 @@ snapshots: clsx: 2.1.1 react: 18.3.1 + prism-react-renderer@2.4.1(react@19.1.1): + dependencies: + '@types/prismjs': 1.26.4 + clsx: 2.1.1 + react: 19.1.1 + process@0.11.10: {} progress@2.0.3: {} From 9b9d7d872828def36fb4541b94bbef58e50369c0 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 6 Oct 2025 05:33:35 +0530 Subject: [PATCH 2/6] feat: code block component --- apps/www/src/app/test/page.jsx | 258 ------------- .../docs/components/code-block/demo.ts | 362 ++++++++---------- .../docs/components/code-block/index.js | 1 - .../docs/components/code-block/index.mdx | 169 ++++---- .../docs/components/code-block/props.ts | 145 ++++--- .../components/code-block/code-block-code.tsx | 110 +++--- .../code-block/code-block-content.tsx | 23 -- .../code-block/code-block-copy-button.tsx | 31 -- .../code-block/code-block-header.tsx | 70 ---- .../code-block/code-block-language-select.tsx | 41 +- .../components/code-block/code-block-misc.tsx | 112 ++++++ .../components/code-block/code-block-root.tsx | 75 +++- .../code-block/code-block.module.css | 300 +++------------ .../components/code-block/code-block.tsx | 18 +- .../components/code-block/code.module.css | 188 +++++++++ .../components/copy-button/copy-button.tsx | 25 +- .../components/select/select-root.tsx | 4 +- .../hooks/useIsomorphicLayoutEffect.tsx | 4 + 18 files changed, 848 insertions(+), 1088 deletions(-) delete mode 100644 apps/www/src/app/test/page.jsx delete mode 100644 apps/www/src/content/docs/components/code-block/index.js delete mode 100644 packages/raystack/components/code-block/code-block-content.tsx delete mode 100644 packages/raystack/components/code-block/code-block-copy-button.tsx delete mode 100644 packages/raystack/components/code-block/code-block-header.tsx create mode 100644 packages/raystack/components/code-block/code-block-misc.tsx create mode 100644 packages/raystack/components/code-block/code.module.css create mode 100644 packages/raystack/hooks/useIsomorphicLayoutEffect.tsx diff --git a/apps/www/src/app/test/page.jsx b/apps/www/src/app/test/page.jsx deleted file mode 100644 index e2f801ef..00000000 --- a/apps/www/src/app/test/page.jsx +++ /dev/null @@ -1,258 +0,0 @@ -'use client'; -// @ts-nocheck -/* tslint:disable */ -import { CodeBlock } from '@raystack/apsara'; - -export default function Page() { - return ( -
-

- CodeBlock Examples -

- - - code - - - - - - code - - - {/* Basic usage with single language */} -
-

- Basic Usage -

- - - Simple JavaScript function - - - - {`function greetUser(name) { - const message = \`Hello, \${name}!\`; - console.log(message); - return message; -} - -const user = "World"; -const greeting = greetUser(user); -console.log(greeting); // Output: Hello, World!`} - - - -
- - {/* Multi-language example */} -
-

- Multi-Language Example -

- - - Hello World in Multiple Languages - - - - - - JavaScript - - - TypeScript - - - Python - - - Java - - - - - - - - - - {`function greetUser(name) { - const message = \`Hello, \${name}!\`; - console.log(message); - return message; -} - -const user = "World"; -const greeting = greetUser(user);`} - - - - {`interface User { - name: string; - age: number; - email: string; -} - -function greetUser(user: User): string { - const message = \`Hello, \${user.name}!\`; - console.log(message); - return message; -} - -const user: User = { - name: "World", - age: 25, - email: "world@example.com" -}; - -const greeting = greetUser(user);`} - - - - {`class User: - def __init__(self, name: str, age: int, email: str): - self.name = name - self.age = age - self.email = email - -def greet_user(user: User) -> str: - message = f"Hello, {user.name}!" - print(message) - return message - -user = User("World", 25, "world@example.com") -greeting = greet_user(user)`} - - - - {`public class User { - private String name; - private int age; - private String email; - - public User(String name, int age, String email) { - this.name = name; - this.age = age; - this.email = email; - } - - public String getName() { return name; } - public int getAge() { return age; } - public String getEmail() { return email; } -} - -public class Greeter { - public static String greetUser(User user) { - String message = "Hello, " + user.getName() + "!"; - System.out.println(message); - return message; - } - - public static void main(String[] args) { - User user = new User("World", 25, "world@example.com"); - String greeting = greetUser(user); - } -}`} - - - -
- - {/* Configuration options */} -
-

- With Configuration Options -

- - - CSS Styles without Line Numbers - - - - {`.user-card { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 12px; - padding: 24px; - color: white; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - transition: transform 0.3s ease; -} - -.user-card:hover { - transform: translateY(-4px); -} - -.user-name { - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 8px; -} - -.user-details { - opacity: 0.9; - font-size: 0.9rem; -}`} - - - -
- - {/* Minimal example */} -
-

- Minimal Example -

- - - - {` - - - - - User Greeting - - -
-

Hello, World!

-
-

Age: 25

-

Email: world@example.com

-
-
- -`} -
-
-
-
-
- ); -} diff --git a/apps/www/src/content/docs/components/code-block/demo.ts b/apps/www/src/content/docs/components/code-block/demo.ts index 8e3d8efd..bee5adfa 100644 --- a/apps/www/src/content/docs/components/code-block/demo.ts +++ b/apps/www/src/content/docs/components/code-block/demo.ts @@ -2,238 +2,192 @@ import { getPropsString } from '@/lib/utils'; -export const getCode = (props: Record) => { - const { children, ...rest } = props; - return `${children}`; +const jsxCode = `{\`function add(a, b) { + return a + b; +}\`}`; + +const tsxCode = `{\`function add(a: number, b: number): number { + return a + b; +}\`}`; + +const longCode = `{\` + + + + + + A simple dialog example + + + + + This is a basic dialog with title and description. + + + + + + + + + +\`}`; + +const getCode = (props: Record) => { + const { children, maxLines, defaultValue = 'jsx', ...rest } = props; + return ` + + Code + + + + + JSX + + + TSX + + + + + + + ${longCode} + ${tsxCode} + + + `; }; export const playground = { type: 'playground', controls: { - language: { - type: 'select', - options: [ - 'javascript', - 'typescript', - 'python', - 'java', - 'css', - 'html', - 'json' - ], - defaultValue: 'javascript' - }, - showLineNumbers: { + hideLineNumbers: { type: 'checkbox', - defaultValue: true + defaultValue: false }, - showCopyButton: { - type: 'checkbox', - defaultValue: true + maxLines: { + type: 'number', + defaultValue: 0, + initialValue: 10, + min: 0, + max: 20 }, - maxHeight: { - type: 'text', - defaultValue: '' + defaultValue: { + type: 'select', + options: ['jsx', 'tsx'], + defaultValue: 'jsx' } }, - code: `function greetUser(name) { - const message = \`Hello, \${name}!\`; - console.log(message); - return message; -} - -const user = "World"; -const greeting = greetUser(user);` + getCode }; export const basicDemo = { type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; - -function MyComponent() { - const code = \`function hello() { - console.log('Hello, world!'); -}\`; - - return ( - - {code} - - ); -}` + code: ` + + + ${jsxCode} + + +` }; -export const withoutHeaderDemo = { +export const withHeaderDemo = { type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; - -function MyComponent() { - const code = \`function hello() { - console.log('Hello, world!'); -}\`; - - return ( - - {code} - - ); -}` + code: ` + + Header Example + + + + + ${jsxCode} + + + ` }; -export const customLanguageDemo = { +export const languageSwitcherDemo = { type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; - -function MyComponent() { - const pythonCode = \`def fibonacci(n): - if n <= 1: - return n - return fibonacci(n-1) + fibonacci(n-2) - -print(fibonacci(10))\`; - - return ( - - {pythonCode} - - ); -}` + code: ` + + Code + + + + + JSX + + + TSX + + + + + + ${jsxCode} + ${tsxCode} + + ` }; export const noLineNumbersDemo = { type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; - -function MyComponent() { - const code = \`function hello() { - console.log('Hello, world!'); -}\`; - - return ( - - {code} - - ); -}` + code: ` + + + ${jsxCode} + + + ` }; -export const maxHeightDemo = { +export const collapsibleDemo = { type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; - -function MyComponent() { - const longCode = \`// This is a very long code example -function processData(data) { - const result = []; - - for (let i = 0; i < data.length; i++) { - const item = data[i]; - - if (item.type === 'user') { - result.push({ - id: item.id, - name: item.name, - email: item.email, - createdAt: new Date(item.createdAt), - updatedAt: new Date(item.updatedAt) - }); - } else if (item.type === 'admin') { - result.push({ - id: item.id, - name: item.name, - role: item.role, - permissions: item.permissions, - createdAt: new Date(item.createdAt) - }); - } - } - - return result; -} - -export default processData;\`; - - return ( - - {longCode} - - ); -}` + code: ` + + + ${longCode} + + + +` }; -export const compositePatternDemo = { +export const copyButtonDemo = { type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; - -function MyComponent() { - const code = \`function hello() { - console.log('Hello, world!'); -}\`; - - return ( -
- {/* Using Root component */} - - {code} - - - {/* Using Content component */} - - {code} - - - {/* Using Header component */} - -
- ); -}` -}; - -export const languageSwitcherDemo = { - type: 'code', - code: `import { CodeBlockComponent } from '@raystack/apsara'; -import { useState } from 'react'; - -function MyComponent() { - const [currentLanguage, setCurrentLanguage] = useState('javascript'); - - const codeExamples = { - javascript: \`function greet(name) { - return \`Hello, \${name}!\`; -}\`, - typescript: \`function greet(name: string): string { - return \`Hello, \${name}!\`; -}\`, - python: \`def greet(name): - return f"Hello, {name}!"\`, - java: \`public String greet(String name) { - return "Hello, " + name + "!"; -}\` - }; - - return ( -
- - - {codeExamples[currentLanguage]} - -
- ); -}` + tabs: [ + { + name: 'Floating', + code: ` + + + + ${jsxCode} + + + + ` + }, + { + name: 'In header', + code: ` + + + Code + + + + + ${jsxCode} + + + ` + } + ] }; diff --git a/apps/www/src/content/docs/components/code-block/index.js b/apps/www/src/content/docs/components/code-block/index.js deleted file mode 100644 index 445d8aa7..00000000 --- a/apps/www/src/content/docs/components/code-block/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './index.mdx'; diff --git a/apps/www/src/content/docs/components/code-block/index.mdx b/apps/www/src/content/docs/components/code-block/index.mdx index 46c50f30..9ec944c9 100644 --- a/apps/www/src/content/docs/components/code-block/index.mdx +++ b/apps/www/src/content/docs/components/code-block/index.mdx @@ -1,165 +1,128 @@ --- title: Code Block -description: A composite component for displaying code with syntax highlighting, line numbers, and copy functionality using the composite pattern. +description: A flexible composite component for displaying code with syntax highlighting, line numbers, copy functionality, and multi-language support. --- import { playground, basicDemo, - withoutHeaderDemo, - customLanguageDemo, - noLineNumbersDemo, - maxHeightDemo, - compositePatternDemo, + withHeaderDemo, languageSwitcherDemo, + noLineNumbersDemo, + collapsibleDemo, + copyButtonDemo, } from "./demo.ts"; -{/* */} + ## Usage ```tsx -import { CodeBlockComponent } from '@raystack/apsara' +import { CodeBlock } from '@raystack/apsara' ``` -## Component Props +## Component Structure -### CodeBlockComponent.Root Props +The CodeBlock component uses a composite pattern, providing modular sub-components for flexible code display: - +- `CodeBlock` - Root container that manages state and context +- `CodeBlock.Header` - Optional header section for labels and controls +- `CodeBlock.Content` - Main content area containing code blocks +- `CodeBlock.Label` - Text label displayed in the header +- `CodeBlock.Code` - Individual code block with syntax highlighting +- `CodeBlock.CopyButton` - Copy functionality (floating or inline variants) +- `CodeBlock.LanguageSelect` - Language selection dropdown container +- `CodeBlock.LanguageSelectTrigger` - Button to open language selection +- `CodeBlock.LanguageSelectContent` - Language select content container +- `CodeBlock.LanguageSelectItem` - Individual language option +- `CodeBlock.CollapseTrigger` - Button to expand/collapse long code blocks -### CodeBlockComponent.Header Props +## API Reference - +### CodeBlock Props -### CodeBlockComponent.Content Props + - +### CodeBlock.Header Props -## Examples - -### Basic Usage + - +### CodeBlock.Content Props -The most basic usage using `CodeBlockComponent.Root` for displaying code content. + -### Without Header +### CodeBlock.Label Props - + -Using `CodeBlockComponent.Root` displays only the code content with a floating copy button. +### CodeBlock.LanguageSelect Props -### Custom Language + - +### CodeBlock.LanguageSelectTrigger Props -Specify different programming languages for proper syntax highlighting. Supported languages include JavaScript, TypeScript, Python, Java, CSS, HTML, and more. + -### No Line Numbers +### CodeBlock.CopyButton Props - + -Disable line numbers by setting `showLineNumbers={false}`. +### CodeBlock.Code Props -### Max Height with Scrolling + - +### CodeBlock.CollapseTrigger Props -Set a maximum height to create scrollable code blocks for long code snippets. + -### Composite Pattern +## Examples - +### Basic Usage -The CodeBlock component follows a composite pattern, allowing you to use individual parts: +The most basic usage using `CodeBlock` with `CodeBlock.Code` for displaying code content with a floating copy button. -- `CodeBlockComponent.Root` - Core code rendering with floating copy button -- `CodeBlockComponent.Header` - Header with label, language switcher, and copy button -- `CodeBlockComponent.Content` - Content-only version with optional copy button + -### Language Switcher +### With Header - +Add a structured header with labels and controls using `CodeBlock.Header`, `CodeBlock.Label`, and `CodeBlock.CopyButton` for better organization and user experience. -Use the `CodeBlockComponent.Header` with `onLanguageChange` callback to implement dynamic language switching with custom language options. + -## Composite Pattern Usage +### Language Switcher -The CodeBlock component is designed to be used exclusively through the composite pattern: +Support multiple programming languages by including multiple `CodeBlock.Code` components with different language values. +Use `CodeBlock.LanguageSelect` to provide a dropdown for users to switch between languages. The component automatically displays the code block matching the selected language value, which can be controlled programmatically using the `value` and `onValueChange` props. -```tsx -import { CodeBlockComponent } from '@raystack/apsara'; - -// Root component - displays code with floating copy button - - {code} - - -// Header component - displays label, language switcher, and copy button - - -// Content component - displays code with optional copy button - - {code} - -``` + -## Features +### No Line Numbers -### Syntax Highlighting +Hide line numbers by setting the `hideLineNumbers` prop to `true` on the root `CodeBlock` component for a cleaner appearance. -The CodeBlock component uses Prism.js for syntax highlighting, supporting a wide range of programming languages with the VS Dark theme. + -### Copy Functionality +### Collapsible Code -- **Header Mode**: Copy button appears in the header toolbar (`CodeBlockComponent.Header`) -- **Floating Mode**: Copy button appears as a floating overlay (`CodeBlockComponent.Root`) -- **Content Mode**: Optional copy button (`CodeBlockComponent.Content`) -- **Visual Feedback**: Button shows a checkmark icon when copy is successful -- **Auto Reset**: Returns to copy icon after 1 second +For long code snippets, use the `maxLines` prop to create collapsible code blocks. When the code exceeds the specified number of lines, a `CodeBlock.CollapseTrigger` button appears, allowing users to expand or collapse the content. -### Line Numbers + -Line numbers are displayed by default and can be toggled off. They help with code navigation and referencing specific lines. -### Language Switcher +### Copy Button Variants -The header includes a dropdown to switch between different programming languages, useful for displaying the same code in multiple languages. -### Responsive Design +The `CodeBlock.CopyButton` component offers two placement options: +- **Floating**: Overlays on the code block using `variant="floating"` +- **Inline**: Positioned within the header alongside other controls -The component is fully responsive and adapts to different screen sizes: -- Header controls stack vertically on mobile -- Copy button remains accessible -- Code content scrolls horizontally when needed + ## Accessibility -- **Keyboard Navigation**: All interactive elements are keyboard accessible -- **Screen Reader Support**: Proper ARIA labels and roles -- **Focus Management**: Clear focus indicators -- **Color Contrast**: Meets WCAG guidelines for text contrast - -## Best Practices - -1. **Use Appropriate Languages**: Always specify the correct language for proper syntax highlighting -2. **Consider Content Length**: Use `maxHeight` for very long code snippets -3. **Provide Context**: Use meaningful `label` values in headers -4. **Test Copy Functionality**: Ensure the code content is properly formatted for copying -5. **Mobile Considerations**: Test on mobile devices to ensure readability -6. **Composite Pattern**: Always use the composite pattern - no generic `CodeBlock` component exists - -## Related Components +The CodeBlock component is built with accessibility in mind: -- [Copy Button](/docs/components/copy-button) - Standalone copy functionality -- [Text](/docs/components/text) - For displaying regular text content -- [Container](/docs/components/container) - For layout and spacing +- **Keyboard Navigation**: All interactive elements support keyboard navigation +- **Screen Reader Support**: Proper ARIA labels and semantic roles for screen readers +- **Focus Management**: Clear visual focus indicators for keyboard users +- **Color Contrast**: Meets WCAG guidelines for sufficient text contrast ratios diff --git a/apps/www/src/content/docs/components/code-block/props.ts b/apps/www/src/content/docs/components/code-block/props.ts index 7e49cbfc..fe420e49 100644 --- a/apps/www/src/content/docs/components/code-block/props.ts +++ b/apps/www/src/content/docs/components/code-block/props.ts @@ -1,110 +1,161 @@ -export type CodeBlockRootProps = { +import { ReactNode } from 'react'; + +export interface CodeBlockProps { /** - * The code content to display + * The selected value of the code block to be displayed */ - children: React.ReactNode; + value?: string; /** - * Programming language for syntax highlighting - * @defaultValue "javascript" + * Default value of the code block to be displayed + */ + defaultValue?: string; + + /** + * Callback when the value changes + */ + onValueChange?: (value: string) => void; + + /** + * Whether to hide line numbers + * @defaultValue false + */ + hideLineNumbers?: boolean; + + /** + * Maximum number of lines to display before collapsing + * If > 0, the code block can be collapsed + * @defaultValue 0 */ - language?: string; + maxLines?: number; /** - * Whether to show line numbers + * Whether the code block is collapsed + */ + collapsed?: boolean; + + /** + * Default collapsed state * @defaultValue true */ - showLineNumbers?: boolean; + defaultCollapsed?: boolean; + + /** + * Callback when collapse state changes + */ + onCollapseChange?: (collapsed: boolean) => void; /** - * Maximum height of the code block + * The code content to display */ - maxHeight?: string | number; + children: ReactNode; /** * Additional CSS class name */ className?: string; -}; +} -export type CodeBlockHeaderProps = { +export interface CodeBlockCodeProps { /** - * Label text displayed in the header - * @defaultValue "Code" + * Programming language for syntax highlighting */ - label?: string; + language: string; /** - * Current programming language - * @defaultValue "javascript" + * The unique value of the code block + * If not provided, the language will be used as the value */ - language?: string; + value?: string; /** - * Available languages for the language switcher - * @defaultValue ["javascript", "typescript", "python", "java", "css", "html"] + * The code content to display */ - availableLanguages?: string[]; + children: string; /** - * Callback when language is changed + * Additional CSS class name */ - onLanguageChange?: (language: string) => void; + className?: string; +} +export interface CodeBlockHeaderProps { /** - * The code content to copy + * The content to display in the header */ - codeContent?: string; + children: ReactNode; /** - * Whether to show the line wrap toggle button - * @defaultValue true + * Additional CSS class name */ - showLineWrapToggle?: boolean; + className?: string; +} +export interface CodeBlockContentProps { /** - * Whether to show the copy button - * @defaultValue true + * The content to display */ - showCopyButton?: boolean; + children: ReactNode; /** * Additional CSS class name */ className?: string; -}; +} -export type CodeBlockContentProps = { +export interface CodeBlockLabelProps { /** - * The code content to display + * The label text to display */ - children: React.ReactNode; + children: ReactNode; /** - * Programming language for syntax highlighting - * @defaultValue "javascript" + * Additional CSS class name */ - language?: string; + className?: string; +} +export interface CodeBlockLanguageSelectProps { /** - * Whether to show line numbers - * @defaultValue true + * Available languages for selection */ - showLineNumbers?: boolean; + children: ReactNode; /** - * Maximum height of the code block + * Additional CSS class name */ - maxHeight?: string | number; + className?: string; +} +export interface CodeBlockLanguageSelectTriggerProps { /** - * Whether to show the copy button - * @defaultValue true + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockCopyButtonProps { + /** + * Copy button variant + * @defaultValue "default" + */ + variant?: 'floating' | 'default'; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockCollapseTriggerProps { + /** + * The text to display on the collapse trigger + * @defaultValue "Show Code" */ - showCopyButton?: boolean; + children?: ReactNode; /** * Additional CSS class name */ className?: string; -}; +} diff --git a/packages/raystack/components/code-block/code-block-code.tsx b/packages/raystack/components/code-block/code-block-code.tsx index 82a2230c..0eac0f95 100644 --- a/packages/raystack/components/code-block/code-block-code.tsx +++ b/packages/raystack/components/code-block/code-block-code.tsx @@ -1,54 +1,80 @@ -import { Highlight, themes } from 'prism-react-renderer'; +'use client'; + +import { cx } from 'class-variance-authority'; +import { Highlight } from 'prism-react-renderer'; import { Language } from 'prism-react-renderer'; -import { HTMLAttributes, forwardRef, useEffect } from 'react'; +import { HTMLAttributes, forwardRef, memo } from 'react'; +import { useIsomorphicLayoutEffect } from '~/hooks/useIsomorphicLayoutEffect'; import { useCodeBlockContext } from './code-block-root'; import styles from './code-block.module.css'; +import code from './code.module.css'; export interface CodeBlockCodeProps extends HTMLAttributes { children: string; + language: Language; + value?: string; className?: string; } -const CodeBlockCode = forwardRef( - ({ children, className, ...props }, ref) => { - const { language, hideLineNumbers, setCode } = useCodeBlockContext(); +const emptyTheme = { plain: {}, styles: [] }; + +export const CodeBlockCode = forwardRef( + ({ children, language, className, value, ...props }, ref) => { + const { value: contextValue, setCode, setValue } = useCodeBlockContext(); + const computedValue = value ?? language; + const isContextValueDefined = !!contextValue; + const shouldRender = + !isContextValueDefined || contextValue === computedValue; + const content = children.trim(); - useEffect(() => { - setCode(children); - }, [children, setCode]); + useIsomorphicLayoutEffect(() => { + // if value is not defined, set the value + if (!isContextValueDefined) setValue(language); + // if should render, store the code + if (shouldRender) setCode(content); + }, [ + content, + setCode, + shouldRender, + setValue, + language, + isContextValueDefined + ]); + + if (!shouldRender) return null; return ( -
- - {({ - className: highlightClassName, - style, - tokens, - getLineProps, - getTokenProps - }) => ( +
+ +
+ ); + } +); + +CodeBlockCode.displayName = 'CodeBlockCode'; + +const CodeHighlight = memo( + ({ content, language }: { content: string; language: Language }) => { + const { hideLineNumbers, maxLines, collapsed } = useCodeBlockContext(); + const canCollapse = maxLines && maxLines > 0; + return ( + + {({ + className: highlightClassName, + style, + tokens, + getLineProps, + getTokenProps + }) => { + const renderedTokens = + canCollapse && collapsed ? tokens.slice(0, maxLines) : tokens; + return (
-              {tokens.map((line, i) => (
-                
+ {renderedTokens.map((line, i) => ( +
{!hideLineNumbers && ( {i + 1} )} @@ -60,13 +86,9 @@ const CodeBlockCode = forwardRef(
))}
- )} -
-
+ ); + }} + ); } ); - -CodeBlockCode.displayName = 'CodeBlockCode'; - -export { CodeBlockCode }; diff --git a/packages/raystack/components/code-block/code-block-content.tsx b/packages/raystack/components/code-block/code-block-content.tsx deleted file mode 100644 index fd968a84..00000000 --- a/packages/raystack/components/code-block/code-block-content.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { HTMLAttributes, forwardRef } from 'react'; - -import styles from './code-block.module.css'; - -export interface CodeBlockContentProps extends HTMLAttributes {} - -const CodeBlockContent = forwardRef( - ({ children, className, ...props }, ref) => { - return ( -
- {children} -
- ); - } -); - -CodeBlockContent.displayName = 'CodeBlockContent'; - -export { CodeBlockContent }; diff --git a/packages/raystack/components/code-block/code-block-copy-button.tsx b/packages/raystack/components/code-block/code-block-copy-button.tsx deleted file mode 100644 index 52986216..00000000 --- a/packages/raystack/components/code-block/code-block-copy-button.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { forwardRef } from 'react'; - -import { CopyButton } from '../copy-button'; -import { useCodeBlockContext } from './code-block-root'; -import styles from './code-block.module.css'; - -export interface CodeBlockCopyButtonProps - extends React.HTMLAttributes { - className?: string; -} - -const CodeBlockCopyButton = forwardRef< - HTMLDivElement, - CodeBlockCopyButtonProps ->(({ className, ...props }, ref) => { - const { code = '' } = useCodeBlockContext(); - - return ( -
- -
- ); -}); - -CodeBlockCopyButton.displayName = 'CodeBlockCopyButton'; - -export { CodeBlockCopyButton }; diff --git a/packages/raystack/components/code-block/code-block-header.tsx b/packages/raystack/components/code-block/code-block-header.tsx deleted file mode 100644 index 186a74cc..00000000 --- a/packages/raystack/components/code-block/code-block-header.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { forwardRef } from 'react'; - -import { Text } from '../text'; -import styles from './code-block.module.css'; - -export interface CodeBlockHeaderProps - extends React.HTMLAttributes { - children: React.ReactNode; - className?: string; -} - -export const CodeBlockHeader = forwardRef( - ({ children, className, ...props }, ref) => { - return ( -
-
{children}
-
- ); - } -); - -CodeBlockHeader.displayName = 'CodeBlockHeader'; - -export interface CodeBlockLabelProps - extends React.HTMLAttributes { - children: React.ReactNode; - className?: string; -} - -export const CodeBlockLabel = forwardRef( - ({ children, className, ...props }, ref) => { - return ( -
- {children} -
- ); - } -); - -CodeBlockLabel.displayName = 'CodeBlockLabel'; - -export interface CodeBlockActionProps - extends React.HTMLAttributes { - children: React.ReactNode; - className?: string; -} - -export const CodeBlockAction = forwardRef( - ({ children, className, ...props }, ref) => { - return ( -
- {children} -
- ); - } -); - -CodeBlockAction.displayName = 'CodeBlockAction'; diff --git a/packages/raystack/components/code-block/code-block-language-select.tsx b/packages/raystack/components/code-block/code-block-language-select.tsx index d8e735ba..fe2e2a22 100644 --- a/packages/raystack/components/code-block/code-block-language-select.tsx +++ b/packages/raystack/components/code-block/code-block-language-select.tsx @@ -1,47 +1,34 @@ -import React, { forwardRef } from 'react'; +'use client'; +import { cx } from 'class-variance-authority'; import { Language } from 'prism-react-renderer'; +import { ComponentProps, ElementRef, forwardRef } from 'react'; import { Select } from '../select'; +import { SingleSelectProps } from '../select/select-root'; import { useCodeBlockContext } from './code-block-root'; import styles from './code-block.module.css'; -export interface CodeBlockLanguageSelectProps - extends React.HTMLAttributes { - children: React.ReactNode; - className?: string; -} - -export const CodeBlockLanguageSelect = forwardRef< - HTMLDivElement, - CodeBlockLanguageSelectProps ->(({ children, className, ...props }, ref) => { - const { selectedLanguage, setSelectedLanguage } = useCodeBlockContext(); +export const CodeBlockLanguageSelect = (props: SingleSelectProps) => { + const { value, setValue } = useCodeBlockContext(); const handleValueChange = (value: string) => { - setSelectedLanguage(value as Language); + setValue(value as Language); }; - return ( - - ); -}); + return - - + + - Option 1 - Option 2 + Option 1 + Option 2