diff --git a/apps/www/src/app/playground/page.tsx b/apps/www/src/app/playground/page.tsx index 2d74c8893..0f1e5c6b3 100644 --- a/apps/www/src/app/playground/page.tsx +++ b/apps/www/src/app/playground/page.tsx @@ -17,6 +17,7 @@ export default function Page() { + diff --git a/apps/www/src/components/playground/code-block-examples.tsx b/apps/www/src/components/playground/code-block-examples.tsx new file mode 100644 index 000000000..5e84c6004 --- /dev/null +++ b/apps/www/src/components/playground/code-block-examples.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { CodeBlock, Flex } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function CodeBlockExamples() { + return ( + + + + + Code + + + + + JSX + + + TSX + + + + + + + {` + + + + + + A simple dialog example + + + + + This is a basic dialog with title and description. + + + + + + + + + +`} + {`function add(a: number, b: number): number { + return a + b; +}`} + + + + + + ); +} diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 5978c336f..f853044ee 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -7,6 +7,7 @@ export * from './calendar-examples'; export * from './callout-examples'; export * from './checkbox-examples'; export * from './chip-examples'; +export * from './code-block-examples'; export * from './command-examples'; export * from './container-examples'; export * from './data-table-examples'; diff --git a/apps/www/src/components/playground/select-examples.tsx b/apps/www/src/components/playground/select-examples.tsx index 554987990..2bdf49e03 100644 --- a/apps/www/src/components/playground/select-examples.tsx +++ b/apps/www/src/components/playground/select-examples.tsx @@ -1,38 +1,38 @@ -"use client"; +'use client'; -import { Select, Flex } from "@raystack/apsara"; -import PlaygroundLayout from "./playground-layout"; +import { Flex, Select } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; export function SelectExamples() { return ( - - + + 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 000000000..bee5adfa7 --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/demo.ts @@ -0,0 +1,193 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +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: { + hideLineNumbers: { + type: 'checkbox', + defaultValue: false + }, + maxLines: { + type: 'number', + defaultValue: 0, + initialValue: 10, + min: 0, + max: 20 + }, + defaultValue: { + type: 'select', + options: ['jsx', 'tsx'], + defaultValue: 'jsx' + } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: ` + + + ${jsxCode} + + +` +}; + +export const withHeaderDemo = { + type: 'code', + code: ` + + Header Example + + + + + ${jsxCode} + + + ` +}; + +export const languageSwitcherDemo = { + type: 'code', + code: ` + + Code + + + + + JSX + + + TSX + + + + + + ${jsxCode} + ${tsxCode} + + ` +}; + +export const noLineNumbersDemo = { + type: 'code', + code: ` + + + ${jsxCode} + + + ` +}; + +export const collapsibleDemo = { + type: 'code', + code: ` + + + ${longCode} + + + +` +}; + +export const copyButtonDemo = { + type: 'code', + tabs: [ + { + name: 'Floating', + code: ` + + + + ${jsxCode} + + + + ` + }, + { + name: 'In header', + code: ` + + + Code + + + + + ${jsxCode} + + + ` + } + ] +}; 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 000000000..3414e5ad1 --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/index.mdx @@ -0,0 +1,129 @@ +--- +title: Code Block +description: A flexible composite component for displaying code with syntax highlighting, line numbers, copy functionality, and multi-language support. +tag: new +--- + +import { + playground, + basicDemo, + withHeaderDemo, + languageSwitcherDemo, + noLineNumbersDemo, + collapsibleDemo, + copyButtonDemo, +} from "./demo.ts"; + + + +## Usage + +```tsx +import { CodeBlock } from '@raystack/apsara' +``` + +## Component Structure + +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 + +## API Reference + +### CodeBlock Props + + + +### CodeBlock.Header Props + + + +### CodeBlock.Content Props + + + +### CodeBlock.Label Props + + + +### CodeBlock.LanguageSelect Props + + + +### CodeBlock.LanguageSelectTrigger Props + + + +### CodeBlock.CopyButton Props + + + +### CodeBlock.Code Props + + + +### CodeBlock.CollapseTrigger Props + + + +## Examples + +### Basic Usage + +The most basic usage using `CodeBlock` with `CodeBlock.Code` for displaying code content with a floating copy button. + + + +### With Header + +Add a structured header with labels and controls using `CodeBlock.Header`, `CodeBlock.Label`, and `CodeBlock.CopyButton` for better organization and user experience. + + + +### Language Switcher + +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. + + + +### No Line Numbers + +Hide line numbers by setting the `hideLineNumbers` prop to `true` on the root `CodeBlock` component for a cleaner appearance. + + + +### Collapsible Code + +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. + + + + +### Copy Button Variants + + +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 + + + +## Accessibility + +The CodeBlock component is built with accessibility in mind: + +- **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 new file mode 100644 index 000000000..fe420e49c --- /dev/null +++ b/apps/www/src/content/docs/components/code-block/props.ts @@ -0,0 +1,161 @@ +import { ReactNode } from 'react'; + +export interface CodeBlockProps { + /** + * The selected value of the code block to be displayed + */ + value?: string; + + /** + * 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 + */ + maxLines?: number; + + /** + * Whether the code block is collapsed + */ + collapsed?: boolean; + + /** + * Default collapsed state + * @defaultValue true + */ + defaultCollapsed?: boolean; + + /** + * Callback when collapse state changes + */ + onCollapseChange?: (collapsed: boolean) => void; + + /** + * The code content to display + */ + children: ReactNode; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockCodeProps { + /** + * Programming language for syntax highlighting + */ + language: string; + + /** + * The unique value of the code block + * If not provided, the language will be used as the value + */ + value?: string; + + /** + * The code content to display + */ + children: string; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockHeaderProps { + /** + * The content to display in the header + */ + children: ReactNode; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockContentProps { + /** + * The content to display + */ + children: ReactNode; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockLabelProps { + /** + * The label text to display + */ + children: ReactNode; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockLanguageSelectProps { + /** + * Available languages for selection + */ + children: ReactNode; + + /** + * Additional CSS class name + */ + className?: string; +} + +export interface CodeBlockLanguageSelectTriggerProps { + /** + * 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" + */ + children?: ReactNode; + + /** + * Additional CSS class name + */ + className?: string; +} diff --git a/biome.json b/biome.json index 22e7e7b08..3e88ec324 100644 --- a/biome.json +++ b/biome.json @@ -115,6 +115,11 @@ "bracketSpacing": true } }, + "css": { + "parser": { + "cssModules": true + } + }, "overrides": [ { "include": [".eslintrc.{js,cjs}"] }, { 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 000000000..6561ad3fa --- /dev/null +++ b/packages/raystack/components/code-block/__tests__/code-block.test.tsx @@ -0,0 +1,199 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ComponentProps } from 'react'; +import { CodeBlock } from '../code-block'; + +// Mock the clipboard API +const mockCopy = vi.fn(); +vi.mock('~/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ + copy: mockCopy + }) +})); + +// Mock icon components used inside CopyButton to avoid invalid element errors +vi.mock('~/icons', () => ({ + CheckCircleFilledIcon: () => null +})); +vi.mock('@radix-ui/react-icons', () => ({ + CopyIcon: () => null, + ChevronDownIcon: () => null +})); + +const JAVASCRIPT_CODE = `function hello() { + console.log('Hello, world!'); +}`; + +const PYTHON_CODE = `def hello(): + print("Hello, world!")`; + +const BasicCodeBlock = ({ + children, + hasMultipleCodeBlocks = false, + hasFloatingCopyButton = false, + hasCollapseTrigger = false, + ...props +}: ComponentProps & { + hasMultipleCodeBlocks?: boolean; + hasFloatingCopyButton?: boolean; + hasCollapseTrigger?: boolean; +}) => { + return ( + + {children} + + {JAVASCRIPT_CODE} + {hasMultipleCodeBlocks && ( + {PYTHON_CODE} + )} + {hasFloatingCopyButton && ( + + )} + {hasCollapseTrigger && } + + + ); +}; + +const LanguageSelectCodeBlock = ( + props: ComponentProps +) => { + return ( + + + Code + + + + + JavaScript + + + Python + + + + + + ); +}; + +describe('CodeBlock', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCopy.mockResolvedValue(true); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + describe('Basic Rendering', () => { + it('renders with content only', () => { + render(); + + expect(screen.getByText('function')).toBeInTheDocument(); + expect(screen.getByText('hello')).toBeInTheDocument(); + }); + + it('renders with header and content', () => { + render( + + + JavaScript + + + ); + + expect(screen.getByTestId('label')).toBeInTheDocument(); + }); + + it('renders multiple code blocks with different languages', () => { + render(); + + expect(screen.getByText('function')).toBeInTheDocument(); + expect(screen.queryByText('def')).not.toBeInTheDocument(); + }); + }); + + describe('Line Numbers', () => { + it('shows line numbers by default', () => { + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('hides line numbers when hideLineNumbers is true', () => { + render(); + + expect(screen.queryByText('1')).not.toBeInTheDocument(); + expect(screen.queryByText('2')).not.toBeInTheDocument(); + }); + }); + + describe('Language Selection', () => { + it('renders language select when provided', () => { + render(); + + expect(screen.getByTestId('language-select-trigger')).toBeInTheDocument(); + }); + it('updates language when language select is changed', () => { + const { rerender } = render(); + + expect(screen.getByText('JavaScript')).toBeInTheDocument(); + expect(screen.getByText('function')).toBeInTheDocument(); + expect(screen.queryByText('def')).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText('Python')).toBeInTheDocument(); + expect(screen.getByText('def')).toBeInTheDocument(); + expect(screen.queryByText('function')).not.toBeInTheDocument(); + }); + }); + + describe('Copy Button', () => { + it('copies code to clipboard when copy button is clicked', async () => { + const user = userEvent.setup(); + render( + + + + + + ); + + const copyButton = screen.getByTestId('copy-button'); + expect(copyButton).toBeInTheDocument(); + await user.click(copyButton); + + expect(mockCopy).toHaveBeenCalled(); + }); + + it('copies code to clipboard when floating copy button is clicked', async () => { + const user = userEvent.setup(); + + render(); + + const copyButton = screen.getByTestId('floating-copy-button'); + expect(copyButton).toBeInTheDocument(); + await user.click(copyButton); + + expect(mockCopy).toHaveBeenCalled(); + }); + }); + + describe('Collapse Functionality', () => { + it('renders collapse trigger when maxLines is set and code exceeds limit', () => { + render(); + + expect(screen.getByText('Show Code')).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 000000000..759616d42 --- /dev/null +++ b/packages/raystack/components/code-block/code-block-code.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { Highlight } from 'prism-react-renderer'; +import { Language } from 'prism-react-renderer'; +import { HTMLAttributes, forwardRef, memo } from 'react'; +import { useIsomorphicLayoutEffect } from '~/hooks'; +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 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(); + + 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 ( +
+ +
+ ); + } +); + +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 ( +
+              {renderedTokens.map((line, i) => (
+                
+ {!hideLineNumbers && ( + {i + 1} + )} + + {line.map((token, key) => ( + + ))} + +
+ ))} +
+ ); + }} +
+ ); + } +); 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 000000000..fe2e2a225 --- /dev/null +++ b/packages/raystack/components/code-block/code-block-language-select.tsx @@ -0,0 +1,39 @@ +'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 const CodeBlockLanguageSelect = (props: SingleSelectProps) => { + const { value, setValue } = useCodeBlockContext(); + + const handleValueChange = (value: string) => { + setValue(value as Language); + }; + return