@fragments-sdk/ui 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -2
- package/fragments.json +1 -1
- package/package.json +11 -5
- package/src/components/Accordion/Accordion.fragment.tsx +186 -0
- package/src/components/Accordion/Accordion.module.scss +111 -0
- package/src/components/Accordion/index.tsx +271 -0
- package/src/components/Alert/Alert.fragment.tsx +67 -42
- package/src/components/Alert/Alert.module.scss +31 -21
- package/src/components/Alert/index.tsx +202 -73
- package/src/components/AppShell/AppShell.fragment.tsx +315 -0
- package/src/components/AppShell/AppShell.module.scss +213 -0
- package/src/components/AppShell/index.tsx +398 -0
- package/src/components/Avatar/Avatar.fragment.tsx +2 -2
- package/src/components/Avatar/index.tsx +8 -9
- package/src/components/Badge/Badge.fragment.tsx +2 -2
- package/src/components/Badge/Badge.module.scss +16 -10
- package/src/components/Badge/index.tsx +20 -6
- package/src/components/Box/Box.fragment.tsx +168 -0
- package/src/components/Box/Box.module.scss +84 -0
- package/src/components/Box/index.tsx +78 -0
- package/src/components/Button/Button.fragment.tsx +2 -2
- package/src/components/Button/Button.module.scss +42 -0
- package/src/components/Button/index.tsx +67 -33
- package/src/components/ButtonGroup/ButtonGroup.fragment.tsx +153 -0
- package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
- package/src/components/ButtonGroup/index.tsx +40 -0
- package/src/components/Card/Card.fragment.tsx +52 -26
- package/src/components/Card/Card.module.scss +52 -5
- package/src/components/Card/index.tsx +154 -53
- package/src/components/Chart/Chart.fragment.tsx +213 -0
- package/src/components/Chart/Chart.module.scss +123 -0
- package/src/components/Chart/index.tsx +267 -0
- package/src/components/Checkbox/Checkbox.fragment.tsx +1 -1
- package/src/components/Checkbox/Checkbox.module.scss +4 -4
- package/src/components/Checkbox/index.tsx +3 -4
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +460 -0
- package/src/components/CodeBlock/CodeBlock.module.scss +362 -0
- package/src/components/CodeBlock/index.tsx +599 -0
- package/src/components/Collapsible/Collapsible.fragment.tsx +199 -0
- package/src/components/Collapsible/Collapsible.module.scss +117 -0
- package/src/components/Collapsible/index.tsx +219 -0
- package/src/components/ColorPicker/ColorPicker.fragment.tsx +196 -0
- package/src/components/ColorPicker/ColorPicker.module.scss +119 -0
- package/src/components/ColorPicker/index.tsx +129 -0
- package/src/components/ConversationList/ConversationList.fragment.tsx +202 -0
- package/src/components/ConversationList/ConversationList.module.scss +160 -0
- package/src/components/ConversationList/index.tsx +254 -0
- package/src/components/Dialog/Dialog.fragment.tsx +12 -3
- package/src/components/Dialog/Dialog.module.scss +26 -7
- package/src/components/Dialog/index.tsx +12 -15
- package/src/components/EmptyState/EmptyState.fragment.tsx +55 -72
- package/src/components/EmptyState/EmptyState.module.scss +9 -9
- package/src/components/EmptyState/index.tsx +104 -69
- package/src/components/Field/Field.fragment.tsx +165 -0
- package/src/components/Field/Field.module.scss +31 -0
- package/src/components/Field/index.tsx +143 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
- package/src/components/Fieldset/Fieldset.module.scss +22 -0
- package/src/components/Fieldset/index.tsx +47 -0
- package/src/components/Form/Form.fragment.tsx +286 -0
- package/src/components/Form/Form.module.scss +8 -0
- package/src/components/Form/index.tsx +53 -0
- package/src/components/Grid/Grid.fragment.tsx +18 -18
- package/src/components/Grid/index.tsx +6 -1
- package/src/components/Header/Header.fragment.tsx +192 -0
- package/src/components/Header/Header.module.scss +208 -0
- package/src/components/Header/index.tsx +363 -0
- package/src/components/Icon/Icon.fragment.tsx +138 -0
- package/src/components/Icon/Icon.module.scss +38 -0
- package/src/components/Icon/index.tsx +58 -0
- package/src/components/Image/Image.fragment.tsx +195 -0
- package/src/components/Image/Image.module.scss +77 -0
- package/src/components/Image/index.tsx +95 -0
- package/src/components/Input/Input.fragment.tsx +1 -1
- package/src/components/Input/Input.module.scss +75 -2
- package/src/components/Input/index.tsx +60 -21
- package/src/components/Link/Link.fragment.tsx +132 -0
- package/src/components/Link/Link.module.scss +67 -0
- package/src/components/Link/index.tsx +57 -0
- package/src/components/List/List.fragment.tsx +152 -0
- package/src/components/List/List.module.scss +71 -0
- package/src/components/List/index.tsx +106 -0
- package/src/components/Listbox/Listbox.fragment.tsx +191 -0
- package/src/components/Listbox/Listbox.module.scss +97 -0
- package/src/components/Listbox/index.tsx +121 -0
- package/src/components/Loading/Loading.fragment.tsx +153 -0
- package/src/components/Loading/Loading.module.scss +256 -0
- package/src/components/Loading/index.tsx +236 -0
- package/src/components/Menu/Menu.fragment.tsx +12 -3
- package/src/components/Menu/Menu.module.scss +17 -1
- package/src/components/Menu/index.tsx +3 -3
- package/src/components/Message/Message.fragment.tsx +200 -0
- package/src/components/Message/Message.module.scss +224 -0
- package/src/components/Message/index.tsx +278 -0
- package/src/components/Popover/Popover.fragment.tsx +13 -4
- package/src/components/Popover/Popover.module.scss +33 -10
- package/src/components/Popover/index.tsx +9 -11
- package/src/components/Progress/Progress.fragment.tsx +1 -1
- package/src/components/Progress/Progress.module.scss +11 -11
- package/src/components/Progress/index.tsx +34 -7
- package/src/components/Prompt/Prompt.fragment.tsx +231 -0
- package/src/components/Prompt/Prompt.module.scss +243 -0
- package/src/components/Prompt/index.tsx +439 -0
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +1 -1
- package/src/components/RadioGroup/RadioGroup.module.scss +10 -7
- package/src/components/RadioGroup/index.tsx +3 -4
- package/src/components/Select/Select.fragment.tsx +10 -1
- package/src/components/Select/Select.module.scss +8 -0
- package/src/components/Select/index.tsx +91 -12
- package/src/components/Separator/Separator.fragment.tsx +1 -1
- package/src/components/Separator/index.tsx +7 -3
- package/src/components/Sidebar/Sidebar.fragment.tsx +11 -2
- package/src/components/Sidebar/Sidebar.module.scss +91 -47
- package/src/components/Sidebar/index.tsx +57 -14
- package/src/components/Skeleton/Skeleton.fragment.tsx +6 -6
- package/src/components/Skeleton/Skeleton.module.scss +11 -0
- package/src/components/Slider/Slider.fragment.tsx +201 -0
- package/src/components/Slider/Slider.module.scss +87 -0
- package/src/components/Slider/index.tsx +88 -0
- package/src/components/Stack/Stack.fragment.tsx +194 -0
- package/src/components/Stack/Stack.module.scss +120 -0
- package/src/components/Stack/index.tsx +148 -0
- package/src/components/Table/Table.fragment.tsx +10 -3
- package/src/components/Table/Table.module.scss +57 -0
- package/src/components/Table/index.tsx +44 -6
- package/src/components/Tabs/Tabs.fragment.tsx +10 -1
- package/src/components/Tabs/Tabs.module.scss +25 -10
- package/src/components/Tabs/index.tsx +11 -8
- package/src/components/Text/Text.fragment.tsx +188 -0
- package/src/components/Text/Text.module.scss +82 -0
- package/src/components/Text/index.tsx +58 -0
- package/src/components/Textarea/Textarea.fragment.tsx +1 -1
- package/src/components/Textarea/index.tsx +3 -7
- package/src/components/Theme/Theme.fragment.tsx +128 -0
- package/src/components/Theme/ThemeToggle.module.scss +82 -0
- package/src/components/Theme/index.tsx +343 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +182 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.module.scss +226 -0
- package/src/components/ThinkingIndicator/index.tsx +258 -0
- package/src/components/Toast/Toast.fragment.tsx +6 -6
- package/src/components/Toast/Toast.module.scss +16 -1
- package/src/components/Toast/index.tsx +27 -11
- package/src/components/Toggle/Toggle.fragment.tsx +1 -1
- package/src/components/Toggle/Toggle.module.scss +25 -10
- package/src/components/Toggle/index.tsx +12 -0
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +207 -0
- package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
- package/src/components/ToggleGroup/index.tsx +144 -0
- package/src/components/Tooltip/Tooltip.fragment.tsx +3 -3
- package/src/components/Tooltip/Tooltip.module.scss +4 -4
- package/src/components/Tooltip/index.tsx +4 -2
- package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
- package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
- package/src/components/VisuallyHidden/index.tsx +29 -0
- package/src/index.ts +278 -3
- package/src/recipes/AIChat.recipe.ts +266 -0
- package/src/recipes/AppShell.recipe.ts +175 -0
- package/src/recipes/CardGrid.recipe.ts +6 -2
- package/src/recipes/ChatInterface.recipe.ts +87 -0
- package/src/recipes/CodeExamples.recipe.ts +66 -0
- package/src/recipes/DashboardLayout.recipe.ts +46 -12
- package/src/recipes/DashboardNav.recipe.ts +183 -0
- package/src/recipes/LoginForm.recipe.ts +8 -1
- package/src/recipes/SettingsPage.recipe.ts +37 -20
- package/src/styles/globals.scss +31 -0
- package/src/tokens/_computed.scss +212 -0
- package/src/tokens/_density.scss +171 -0
- package/src/tokens/_derive.scss +287 -0
- package/src/tokens/_index.scss +41 -0
- package/src/tokens/_mixins.scss +95 -1
- package/src/tokens/_palettes.scss +185 -0
- package/src/tokens/_radius.scss +107 -0
- package/src/tokens/_seeds.scss +59 -0
- package/src/tokens/_variables.scss +507 -101
- package/src/utils/a11y.tsx +439 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
5
|
+
import { codeToHtml, BundledTheme } from 'shiki';
|
|
6
|
+
import { TabsRoot, TabsList, Tab, TabsPanel } from '../Tabs';
|
|
7
|
+
import { Button } from '../Button';
|
|
8
|
+
import styles from './CodeBlock.module.scss';
|
|
9
|
+
import '../../styles/globals.scss';
|
|
10
|
+
|
|
11
|
+
export type CodeBlockLanguage =
|
|
12
|
+
| 'tsx'
|
|
13
|
+
| 'typescript'
|
|
14
|
+
| 'javascript'
|
|
15
|
+
| 'jsx'
|
|
16
|
+
| 'bash'
|
|
17
|
+
| 'shell'
|
|
18
|
+
| 'css'
|
|
19
|
+
| 'scss'
|
|
20
|
+
| 'sass'
|
|
21
|
+
| 'json'
|
|
22
|
+
| 'html'
|
|
23
|
+
| 'xml'
|
|
24
|
+
| 'markdown'
|
|
25
|
+
| 'md'
|
|
26
|
+
| 'yaml'
|
|
27
|
+
| 'yml'
|
|
28
|
+
| 'python'
|
|
29
|
+
| 'py'
|
|
30
|
+
| 'ruby'
|
|
31
|
+
| 'go'
|
|
32
|
+
| 'rust'
|
|
33
|
+
| 'java'
|
|
34
|
+
| 'kotlin'
|
|
35
|
+
| 'swift'
|
|
36
|
+
| 'c'
|
|
37
|
+
| 'cpp'
|
|
38
|
+
| 'csharp'
|
|
39
|
+
| 'php'
|
|
40
|
+
| 'sql'
|
|
41
|
+
| 'graphql'
|
|
42
|
+
| 'diff'
|
|
43
|
+
| 'plaintext';
|
|
44
|
+
|
|
45
|
+
/** Available syntax highlighting themes */
|
|
46
|
+
export type CodeBlockTheme =
|
|
47
|
+
| 'synthwave-84'
|
|
48
|
+
| 'github-dark'
|
|
49
|
+
| 'github-light'
|
|
50
|
+
| 'one-dark-pro'
|
|
51
|
+
| 'dracula'
|
|
52
|
+
| 'nord'
|
|
53
|
+
| 'monokai'
|
|
54
|
+
| 'vitesse-dark'
|
|
55
|
+
| 'vitesse-light'
|
|
56
|
+
| 'min-dark'
|
|
57
|
+
| 'min-light';
|
|
58
|
+
|
|
59
|
+
export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
60
|
+
/** Code string to display */
|
|
61
|
+
code: string;
|
|
62
|
+
/** Programming language for syntax highlighting */
|
|
63
|
+
language?: CodeBlockLanguage;
|
|
64
|
+
/** Syntax highlighting theme */
|
|
65
|
+
theme?: CodeBlockTheme;
|
|
66
|
+
/** Show copy button */
|
|
67
|
+
showCopy?: boolean;
|
|
68
|
+
/** Optional title above code block (external label) */
|
|
69
|
+
title?: string;
|
|
70
|
+
/** Optional filename shown in header bar inside code block */
|
|
71
|
+
filename?: string;
|
|
72
|
+
/** Optional caption below code block */
|
|
73
|
+
caption?: string;
|
|
74
|
+
/** Show line numbers */
|
|
75
|
+
showLineNumbers?: boolean;
|
|
76
|
+
/** Starting line number (default: 1) */
|
|
77
|
+
startLineNumber?: number;
|
|
78
|
+
/** Highlight specific lines (e.g., [1, 3, '5-7']) */
|
|
79
|
+
highlightLines?: (number | string)[];
|
|
80
|
+
/** Lines marked as added in diff view */
|
|
81
|
+
addedLines?: (number | string)[];
|
|
82
|
+
/** Lines marked as removed in diff view */
|
|
83
|
+
removedLines?: (number | string)[];
|
|
84
|
+
/** Enable word wrapping for long lines */
|
|
85
|
+
wordWrap?: boolean;
|
|
86
|
+
/** Maximum height in pixels (enables scrolling) */
|
|
87
|
+
maxHeight?: number;
|
|
88
|
+
/** Allow collapsing/expanding the code block */
|
|
89
|
+
collapsible?: boolean;
|
|
90
|
+
/** Initial collapsed state (only applies when collapsible is true) */
|
|
91
|
+
defaultCollapsed?: boolean;
|
|
92
|
+
/** Number of lines to show when collapsed */
|
|
93
|
+
collapsedLines?: number;
|
|
94
|
+
/** Compact mode with reduced padding */
|
|
95
|
+
compact?: boolean;
|
|
96
|
+
/** Show a persistent copy button (always visible, uses Button component) */
|
|
97
|
+
persistentCopy?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function CopyIcon({ className }: { className?: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<svg
|
|
103
|
+
className={className}
|
|
104
|
+
width="14"
|
|
105
|
+
height="14"
|
|
106
|
+
viewBox="0 0 24 24"
|
|
107
|
+
fill="none"
|
|
108
|
+
stroke="currentColor"
|
|
109
|
+
strokeWidth="2"
|
|
110
|
+
strokeLinecap="round"
|
|
111
|
+
strokeLinejoin="round"
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
>
|
|
114
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
115
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
116
|
+
</svg>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function CheckIcon({ className }: { className?: string }) {
|
|
121
|
+
return (
|
|
122
|
+
<svg
|
|
123
|
+
className={className}
|
|
124
|
+
width="14"
|
|
125
|
+
height="14"
|
|
126
|
+
viewBox="0 0 24 24"
|
|
127
|
+
fill="none"
|
|
128
|
+
stroke="currentColor"
|
|
129
|
+
strokeWidth="2"
|
|
130
|
+
strokeLinecap="round"
|
|
131
|
+
strokeLinejoin="round"
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
>
|
|
134
|
+
<polyline points="20 6 9 17 4 12" />
|
|
135
|
+
</svg>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ChevronDownIcon({ className }: { className?: string }) {
|
|
140
|
+
return (
|
|
141
|
+
<svg
|
|
142
|
+
className={className}
|
|
143
|
+
width="14"
|
|
144
|
+
height="14"
|
|
145
|
+
viewBox="0 0 24 24"
|
|
146
|
+
fill="none"
|
|
147
|
+
stroke="currentColor"
|
|
148
|
+
strokeWidth="2"
|
|
149
|
+
strokeLinecap="round"
|
|
150
|
+
strokeLinejoin="round"
|
|
151
|
+
aria-hidden="true"
|
|
152
|
+
>
|
|
153
|
+
<polyline points="6 9 12 15 18 9" />
|
|
154
|
+
</svg>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ChevronUpIcon({ className }: { className?: string }) {
|
|
159
|
+
return (
|
|
160
|
+
<svg
|
|
161
|
+
className={className}
|
|
162
|
+
width="14"
|
|
163
|
+
height="14"
|
|
164
|
+
viewBox="0 0 24 24"
|
|
165
|
+
fill="none"
|
|
166
|
+
stroke="currentColor"
|
|
167
|
+
strokeWidth="2"
|
|
168
|
+
strokeLinecap="round"
|
|
169
|
+
strokeLinejoin="round"
|
|
170
|
+
aria-hidden="true"
|
|
171
|
+
>
|
|
172
|
+
<polyline points="18 15 12 9 6 15" />
|
|
173
|
+
</svg>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function escapeHtml(str: string): string {
|
|
178
|
+
return str
|
|
179
|
+
.replace(/&/g, '&')
|
|
180
|
+
.replace(/</g, '<')
|
|
181
|
+
.replace(/>/g, '>')
|
|
182
|
+
.replace(/"/g, '"')
|
|
183
|
+
.replace(/'/g, ''');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove common leading whitespace from all lines (dedent).
|
|
188
|
+
* This handles template literals that have extra indentation from code formatting.
|
|
189
|
+
*/
|
|
190
|
+
function dedent(str: string): string {
|
|
191
|
+
const lines = str.split('\n');
|
|
192
|
+
|
|
193
|
+
// Find the minimum indentation (ignoring empty lines)
|
|
194
|
+
let minIndent = Infinity;
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
if (line.trim() === '') continue;
|
|
197
|
+
const match = line.match(/^(\s*)/);
|
|
198
|
+
if (match) {
|
|
199
|
+
minIndent = Math.min(minIndent, match[1].length);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// If no indentation found, return as-is
|
|
204
|
+
if (minIndent === Infinity || minIndent === 0) {
|
|
205
|
+
return str;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Remove the common indentation from all lines
|
|
209
|
+
return lines
|
|
210
|
+
.map(line => line.slice(minIndent))
|
|
211
|
+
.join('\n');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Parse line specification into a Set of line numbers.
|
|
216
|
+
* Supports: [1, 3, '5-7'] -> Set {1, 3, 5, 6, 7}
|
|
217
|
+
*/
|
|
218
|
+
function parseLineSpec(spec?: (number | string)[]): Set<number> {
|
|
219
|
+
const lines = new Set<number>();
|
|
220
|
+
if (!spec) return lines;
|
|
221
|
+
|
|
222
|
+
for (const item of spec) {
|
|
223
|
+
if (typeof item === 'number') {
|
|
224
|
+
lines.add(item);
|
|
225
|
+
} else if (typeof item === 'string') {
|
|
226
|
+
const rangeMatch = item.match(/^(\d+)-(\d+)$/);
|
|
227
|
+
if (rangeMatch) {
|
|
228
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
229
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
230
|
+
for (let i = start; i <= end; i++) {
|
|
231
|
+
lines.add(i);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
const num = parseInt(item, 10);
|
|
235
|
+
if (!isNaN(num)) {
|
|
236
|
+
lines.add(num);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Backwards compatibility alias */
|
|
246
|
+
function parseHighlightLines(highlightLines?: (number | string)[]): Set<number> {
|
|
247
|
+
return parseLineSpec(highlightLines);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface ProcessOptions {
|
|
251
|
+
showLineNumbers: boolean;
|
|
252
|
+
startLineNumber: number;
|
|
253
|
+
highlightLines: Set<number>;
|
|
254
|
+
addedLines: Set<number>;
|
|
255
|
+
removedLines: Set<number>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Add line numbers, highlight classes, and diff markers to Shiki HTML output.
|
|
260
|
+
*/
|
|
261
|
+
function processShikiHtml(html: string, options: ProcessOptions): string {
|
|
262
|
+
const { showLineNumbers, startLineNumber, highlightLines, addedLines, removedLines } = options;
|
|
263
|
+
const hasDiff = addedLines.size > 0 || removedLines.size > 0;
|
|
264
|
+
|
|
265
|
+
if (!showLineNumbers && highlightLines.size === 0 && !hasDiff) {
|
|
266
|
+
return html;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Extract the code content from Shiki output
|
|
270
|
+
// Shiki outputs: <pre class="shiki ..."><code>...lines...</code></pre>
|
|
271
|
+
const codeMatch = html.match(/<code[^>]*>([\s\S]*?)<\/code>/);
|
|
272
|
+
if (!codeMatch) return html;
|
|
273
|
+
|
|
274
|
+
const codeContent = codeMatch[1];
|
|
275
|
+
const lines = codeContent.split('\n');
|
|
276
|
+
|
|
277
|
+
// Process each line
|
|
278
|
+
const processedLines = lines.map((line, index) => {
|
|
279
|
+
const lineNum = index + 1;
|
|
280
|
+
const displayLineNum = startLineNumber + index;
|
|
281
|
+
const isHighlighted = highlightLines.has(lineNum);
|
|
282
|
+
const isAdded = addedLines.has(lineNum);
|
|
283
|
+
const isRemoved = removedLines.has(lineNum);
|
|
284
|
+
|
|
285
|
+
const lineClasses = ['line'];
|
|
286
|
+
if (isHighlighted) lineClasses.push('highlighted');
|
|
287
|
+
if (isAdded) lineClasses.push('diff-added');
|
|
288
|
+
if (isRemoved) lineClasses.push('diff-removed');
|
|
289
|
+
|
|
290
|
+
const lineClass = lineClasses.join(' ');
|
|
291
|
+
const diffMarker = isAdded ? '+' : isRemoved ? '-' : ' ';
|
|
292
|
+
|
|
293
|
+
if (showLineNumbers || hasDiff) {
|
|
294
|
+
const lineNumHtml = showLineNumbers
|
|
295
|
+
? `<span class="line-number">${displayLineNum}</span>`
|
|
296
|
+
: '';
|
|
297
|
+
const diffMarkerHtml = hasDiff
|
|
298
|
+
? `<span class="diff-marker">${diffMarker}</span>`
|
|
299
|
+
: '';
|
|
300
|
+
return `<span class="${lineClass}">${lineNumHtml}${diffMarkerHtml}${line}</span>`;
|
|
301
|
+
}
|
|
302
|
+
return `<span class="${lineClass}">${line}</span>`;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Reconstruct the HTML
|
|
306
|
+
return html.replace(
|
|
307
|
+
/<code[^>]*>[\s\S]*?<\/code>/,
|
|
308
|
+
`<code>${processedLines.join('\n')}</code>`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
313
|
+
function CodeBlock(
|
|
314
|
+
{
|
|
315
|
+
code,
|
|
316
|
+
language = 'tsx',
|
|
317
|
+
theme = 'synthwave-84',
|
|
318
|
+
showCopy = true,
|
|
319
|
+
title,
|
|
320
|
+
filename,
|
|
321
|
+
caption,
|
|
322
|
+
showLineNumbers = false,
|
|
323
|
+
startLineNumber = 1,
|
|
324
|
+
highlightLines,
|
|
325
|
+
addedLines,
|
|
326
|
+
removedLines,
|
|
327
|
+
wordWrap = false,
|
|
328
|
+
maxHeight,
|
|
329
|
+
collapsible = false,
|
|
330
|
+
defaultCollapsed = false,
|
|
331
|
+
collapsedLines = 5,
|
|
332
|
+
compact = false,
|
|
333
|
+
persistentCopy = false,
|
|
334
|
+
className,
|
|
335
|
+
...htmlProps
|
|
336
|
+
},
|
|
337
|
+
ref
|
|
338
|
+
) {
|
|
339
|
+
const [copied, setCopied] = useState(false);
|
|
340
|
+
const [highlightedHtml, setHighlightedHtml] = useState<string>('');
|
|
341
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
342
|
+
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
|
343
|
+
|
|
344
|
+
const trimmedCode = dedent(code.trim());
|
|
345
|
+
const codeLines = trimmedCode.split('\n');
|
|
346
|
+
const totalLines = codeLines.length;
|
|
347
|
+
const shouldShowCollapse = collapsible && totalLines > collapsedLines;
|
|
348
|
+
|
|
349
|
+
// Compute visible code when collapsed
|
|
350
|
+
const visibleCode = shouldShowCollapse && isCollapsed
|
|
351
|
+
? codeLines.slice(0, collapsedLines).join('\n')
|
|
352
|
+
: trimmedCode;
|
|
353
|
+
|
|
354
|
+
const highlightSet = parseLineSpec(highlightLines);
|
|
355
|
+
const addedSet = parseLineSpec(addedLines);
|
|
356
|
+
const removedSet = parseLineSpec(removedLines);
|
|
357
|
+
const hasDiff = addedSet.size > 0 || removedSet.size > 0;
|
|
358
|
+
|
|
359
|
+
// Apply syntax highlighting
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
let cancelled = false;
|
|
362
|
+
setIsLoading(true);
|
|
363
|
+
|
|
364
|
+
codeToHtml(visibleCode, {
|
|
365
|
+
lang: language,
|
|
366
|
+
theme: theme as BundledTheme,
|
|
367
|
+
})
|
|
368
|
+
.then((html) => {
|
|
369
|
+
if (!cancelled) {
|
|
370
|
+
const processed = processShikiHtml(html, {
|
|
371
|
+
showLineNumbers,
|
|
372
|
+
startLineNumber,
|
|
373
|
+
highlightLines: highlightSet,
|
|
374
|
+
addedLines: addedSet,
|
|
375
|
+
removedLines: removedSet,
|
|
376
|
+
});
|
|
377
|
+
setHighlightedHtml(processed);
|
|
378
|
+
setIsLoading(false);
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
.catch((err) => {
|
|
382
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
383
|
+
console.error('Syntax highlighting failed:', err);
|
|
384
|
+
}
|
|
385
|
+
if (!cancelled) {
|
|
386
|
+
// Fallback to plain text
|
|
387
|
+
setHighlightedHtml(
|
|
388
|
+
`<pre class="shiki"><code>${escapeHtml(visibleCode)}</code></pre>`
|
|
389
|
+
);
|
|
390
|
+
setIsLoading(false);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
return () => {
|
|
395
|
+
cancelled = true;
|
|
396
|
+
};
|
|
397
|
+
}, [visibleCode, language, theme, showLineNumbers, startLineNumber, highlightSet.size, addedSet.size, removedSet.size]);
|
|
398
|
+
|
|
399
|
+
const handleCopy = useCallback(async () => {
|
|
400
|
+
try {
|
|
401
|
+
// Always copy the full code, even when collapsed
|
|
402
|
+
await navigator.clipboard.writeText(trimmedCode);
|
|
403
|
+
setCopied(true);
|
|
404
|
+
setTimeout(() => setCopied(false), 2000);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
407
|
+
console.error('Failed to copy:', err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}, [trimmedCode]);
|
|
411
|
+
|
|
412
|
+
const toggleCollapsed = useCallback(() => {
|
|
413
|
+
setIsCollapsed((prev) => !prev);
|
|
414
|
+
}, []);
|
|
415
|
+
|
|
416
|
+
const classNames = [
|
|
417
|
+
styles.container,
|
|
418
|
+
showLineNumbers && styles.withLineNumbers,
|
|
419
|
+
hasDiff && styles.withDiff,
|
|
420
|
+
wordWrap && styles.wordWrap,
|
|
421
|
+
compact && styles.compact,
|
|
422
|
+
className,
|
|
423
|
+
]
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.join(' ');
|
|
426
|
+
|
|
427
|
+
const wrapperClasses = [
|
|
428
|
+
styles.wrapper,
|
|
429
|
+
filename && styles.hasHeader,
|
|
430
|
+
persistentCopy && styles.persistentCopyWrapper,
|
|
431
|
+
].filter(Boolean).join(' ');
|
|
432
|
+
|
|
433
|
+
const codeContainerStyle: React.CSSProperties = maxHeight
|
|
434
|
+
? { maxHeight, overflow: 'auto' }
|
|
435
|
+
: {};
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<div ref={ref} {...htmlProps} className={classNames}>
|
|
439
|
+
{title && <div className={styles.title}>{title}</div>}
|
|
440
|
+
<div className={wrapperClasses}>
|
|
441
|
+
{filename && (
|
|
442
|
+
<div className={styles.header}>
|
|
443
|
+
<span className={styles.filename}>{filename}</span>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
{showCopy && !persistentCopy && (
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
onClick={handleCopy}
|
|
450
|
+
className={`${styles.copyButton} ${copied ? styles.copied : ''}`}
|
|
451
|
+
aria-label={copied ? 'Copied!' : 'Copy code'}
|
|
452
|
+
>
|
|
453
|
+
{copied ? (
|
|
454
|
+
<>
|
|
455
|
+
<CheckIcon className={styles.icon} />
|
|
456
|
+
<span>Copied!</span>
|
|
457
|
+
</>
|
|
458
|
+
) : (
|
|
459
|
+
<>
|
|
460
|
+
<CopyIcon className={styles.icon} />
|
|
461
|
+
<span>Copy</span>
|
|
462
|
+
</>
|
|
463
|
+
)}
|
|
464
|
+
</button>
|
|
465
|
+
)}
|
|
466
|
+
{isLoading ? (
|
|
467
|
+
<div className={styles.loading} style={codeContainerStyle}>
|
|
468
|
+
<pre>
|
|
469
|
+
<code>{visibleCode}</code>
|
|
470
|
+
</pre>
|
|
471
|
+
</div>
|
|
472
|
+
) : (
|
|
473
|
+
<div
|
|
474
|
+
className={styles.codeContainer}
|
|
475
|
+
style={codeContainerStyle}
|
|
476
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
477
|
+
/>
|
|
478
|
+
)}
|
|
479
|
+
{persistentCopy && (
|
|
480
|
+
<div className={styles.persistentCopy}>
|
|
481
|
+
<Button
|
|
482
|
+
size="sm"
|
|
483
|
+
onClick={handleCopy}
|
|
484
|
+
aria-label={copied ? 'Copied!' : 'Copy code'}
|
|
485
|
+
icon={copied ? true : false}
|
|
486
|
+
>
|
|
487
|
+
{copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
|
|
488
|
+
</Button>
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
{shouldShowCollapse && (
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={toggleCollapsed}
|
|
495
|
+
className={styles.collapseButton}
|
|
496
|
+
aria-expanded={!isCollapsed}
|
|
497
|
+
aria-label={isCollapsed ? 'Expand code' : 'Collapse code'}
|
|
498
|
+
>
|
|
499
|
+
{isCollapsed ? (
|
|
500
|
+
<>
|
|
501
|
+
<ChevronDownIcon className={styles.icon} />
|
|
502
|
+
<span>Show {totalLines - collapsedLines} more lines</span>
|
|
503
|
+
</>
|
|
504
|
+
) : (
|
|
505
|
+
<>
|
|
506
|
+
<ChevronUpIcon className={styles.icon} />
|
|
507
|
+
<span>Show less</span>
|
|
508
|
+
</>
|
|
509
|
+
)}
|
|
510
|
+
</button>
|
|
511
|
+
)}
|
|
512
|
+
</div>
|
|
513
|
+
{caption && <div className={styles.caption}>{caption}</div>}
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
// ============================================
|
|
520
|
+
// Tabbed Code Block
|
|
521
|
+
// ============================================
|
|
522
|
+
|
|
523
|
+
export interface CodeBlockTab {
|
|
524
|
+
/** Label shown in the tab */
|
|
525
|
+
label: string;
|
|
526
|
+
/** Code string to display */
|
|
527
|
+
code: string;
|
|
528
|
+
/** Programming language for syntax highlighting */
|
|
529
|
+
language?: CodeBlockLanguage;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface TabbedCodeBlockProps {
|
|
533
|
+
/** Array of code tabs */
|
|
534
|
+
tabs: CodeBlockTab[];
|
|
535
|
+
/** Default selected tab (by label) */
|
|
536
|
+
defaultTab?: string;
|
|
537
|
+
/** Show copy button */
|
|
538
|
+
showCopy?: boolean;
|
|
539
|
+
/** Show line numbers */
|
|
540
|
+
showLineNumbers?: boolean;
|
|
541
|
+
/** Syntax highlighting theme (applies to all tabs) */
|
|
542
|
+
theme?: CodeBlockTheme;
|
|
543
|
+
/** Enable word wrapping for long lines */
|
|
544
|
+
wordWrap?: boolean;
|
|
545
|
+
/** Maximum height in pixels (enables scrolling) */
|
|
546
|
+
maxHeight?: number;
|
|
547
|
+
/** Additional class name */
|
|
548
|
+
className?: string;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function TabbedCodeBlock({
|
|
552
|
+
tabs,
|
|
553
|
+
defaultTab,
|
|
554
|
+
showCopy = true,
|
|
555
|
+
showLineNumbers = false,
|
|
556
|
+
theme,
|
|
557
|
+
wordWrap,
|
|
558
|
+
maxHeight,
|
|
559
|
+
className,
|
|
560
|
+
}: TabbedCodeBlockProps) {
|
|
561
|
+
const defaultValue = defaultTab || tabs[0]?.label || '';
|
|
562
|
+
|
|
563
|
+
return (
|
|
564
|
+
<div className={className}>
|
|
565
|
+
<TabsRoot defaultValue={defaultValue}>
|
|
566
|
+
<TabsList variant="pills">
|
|
567
|
+
{tabs.map((tab) => (
|
|
568
|
+
<Tab key={tab.label} value={tab.label}>
|
|
569
|
+
{tab.label}
|
|
570
|
+
</Tab>
|
|
571
|
+
))}
|
|
572
|
+
</TabsList>
|
|
573
|
+
{tabs.map((tab) => (
|
|
574
|
+
<TabsPanel key={tab.label} value={tab.label} flush className={styles.tabbedPanel}>
|
|
575
|
+
<CodeBlockBase
|
|
576
|
+
code={tab.code}
|
|
577
|
+
language={tab.language}
|
|
578
|
+
theme={theme}
|
|
579
|
+
showCopy={showCopy}
|
|
580
|
+
showLineNumbers={showLineNumbers}
|
|
581
|
+
wordWrap={wordWrap}
|
|
582
|
+
maxHeight={maxHeight}
|
|
583
|
+
/>
|
|
584
|
+
</TabsPanel>
|
|
585
|
+
))}
|
|
586
|
+
</TabsRoot>
|
|
587
|
+
</div>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ============================================
|
|
592
|
+
// Export compound component
|
|
593
|
+
// ============================================
|
|
594
|
+
|
|
595
|
+
export const CodeBlock = Object.assign(CodeBlockBase, {
|
|
596
|
+
Tabbed: TabbedCodeBlock,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
export { TabbedCodeBlock };
|