@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.
Files changed (175) hide show
  1. package/README.md +98 -2
  2. package/fragments.json +1 -1
  3. package/package.json +11 -5
  4. package/src/components/Accordion/Accordion.fragment.tsx +186 -0
  5. package/src/components/Accordion/Accordion.module.scss +111 -0
  6. package/src/components/Accordion/index.tsx +271 -0
  7. package/src/components/Alert/Alert.fragment.tsx +67 -42
  8. package/src/components/Alert/Alert.module.scss +31 -21
  9. package/src/components/Alert/index.tsx +202 -73
  10. package/src/components/AppShell/AppShell.fragment.tsx +315 -0
  11. package/src/components/AppShell/AppShell.module.scss +213 -0
  12. package/src/components/AppShell/index.tsx +398 -0
  13. package/src/components/Avatar/Avatar.fragment.tsx +2 -2
  14. package/src/components/Avatar/index.tsx +8 -9
  15. package/src/components/Badge/Badge.fragment.tsx +2 -2
  16. package/src/components/Badge/Badge.module.scss +16 -10
  17. package/src/components/Badge/index.tsx +20 -6
  18. package/src/components/Box/Box.fragment.tsx +168 -0
  19. package/src/components/Box/Box.module.scss +84 -0
  20. package/src/components/Box/index.tsx +78 -0
  21. package/src/components/Button/Button.fragment.tsx +2 -2
  22. package/src/components/Button/Button.module.scss +42 -0
  23. package/src/components/Button/index.tsx +67 -33
  24. package/src/components/ButtonGroup/ButtonGroup.fragment.tsx +153 -0
  25. package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
  26. package/src/components/ButtonGroup/index.tsx +40 -0
  27. package/src/components/Card/Card.fragment.tsx +52 -26
  28. package/src/components/Card/Card.module.scss +52 -5
  29. package/src/components/Card/index.tsx +154 -53
  30. package/src/components/Chart/Chart.fragment.tsx +213 -0
  31. package/src/components/Chart/Chart.module.scss +123 -0
  32. package/src/components/Chart/index.tsx +267 -0
  33. package/src/components/Checkbox/Checkbox.fragment.tsx +1 -1
  34. package/src/components/Checkbox/Checkbox.module.scss +4 -4
  35. package/src/components/Checkbox/index.tsx +3 -4
  36. package/src/components/CodeBlock/CodeBlock.fragment.tsx +460 -0
  37. package/src/components/CodeBlock/CodeBlock.module.scss +362 -0
  38. package/src/components/CodeBlock/index.tsx +599 -0
  39. package/src/components/Collapsible/Collapsible.fragment.tsx +199 -0
  40. package/src/components/Collapsible/Collapsible.module.scss +117 -0
  41. package/src/components/Collapsible/index.tsx +219 -0
  42. package/src/components/ColorPicker/ColorPicker.fragment.tsx +196 -0
  43. package/src/components/ColorPicker/ColorPicker.module.scss +119 -0
  44. package/src/components/ColorPicker/index.tsx +129 -0
  45. package/src/components/ConversationList/ConversationList.fragment.tsx +202 -0
  46. package/src/components/ConversationList/ConversationList.module.scss +160 -0
  47. package/src/components/ConversationList/index.tsx +254 -0
  48. package/src/components/Dialog/Dialog.fragment.tsx +12 -3
  49. package/src/components/Dialog/Dialog.module.scss +26 -7
  50. package/src/components/Dialog/index.tsx +12 -15
  51. package/src/components/EmptyState/EmptyState.fragment.tsx +55 -72
  52. package/src/components/EmptyState/EmptyState.module.scss +9 -9
  53. package/src/components/EmptyState/index.tsx +104 -69
  54. package/src/components/Field/Field.fragment.tsx +165 -0
  55. package/src/components/Field/Field.module.scss +31 -0
  56. package/src/components/Field/index.tsx +143 -0
  57. package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
  58. package/src/components/Fieldset/Fieldset.module.scss +22 -0
  59. package/src/components/Fieldset/index.tsx +47 -0
  60. package/src/components/Form/Form.fragment.tsx +286 -0
  61. package/src/components/Form/Form.module.scss +8 -0
  62. package/src/components/Form/index.tsx +53 -0
  63. package/src/components/Grid/Grid.fragment.tsx +18 -18
  64. package/src/components/Grid/index.tsx +6 -1
  65. package/src/components/Header/Header.fragment.tsx +192 -0
  66. package/src/components/Header/Header.module.scss +208 -0
  67. package/src/components/Header/index.tsx +363 -0
  68. package/src/components/Icon/Icon.fragment.tsx +138 -0
  69. package/src/components/Icon/Icon.module.scss +38 -0
  70. package/src/components/Icon/index.tsx +58 -0
  71. package/src/components/Image/Image.fragment.tsx +195 -0
  72. package/src/components/Image/Image.module.scss +77 -0
  73. package/src/components/Image/index.tsx +95 -0
  74. package/src/components/Input/Input.fragment.tsx +1 -1
  75. package/src/components/Input/Input.module.scss +75 -2
  76. package/src/components/Input/index.tsx +60 -21
  77. package/src/components/Link/Link.fragment.tsx +132 -0
  78. package/src/components/Link/Link.module.scss +67 -0
  79. package/src/components/Link/index.tsx +57 -0
  80. package/src/components/List/List.fragment.tsx +152 -0
  81. package/src/components/List/List.module.scss +71 -0
  82. package/src/components/List/index.tsx +106 -0
  83. package/src/components/Listbox/Listbox.fragment.tsx +191 -0
  84. package/src/components/Listbox/Listbox.module.scss +97 -0
  85. package/src/components/Listbox/index.tsx +121 -0
  86. package/src/components/Loading/Loading.fragment.tsx +153 -0
  87. package/src/components/Loading/Loading.module.scss +256 -0
  88. package/src/components/Loading/index.tsx +236 -0
  89. package/src/components/Menu/Menu.fragment.tsx +12 -3
  90. package/src/components/Menu/Menu.module.scss +17 -1
  91. package/src/components/Menu/index.tsx +3 -3
  92. package/src/components/Message/Message.fragment.tsx +200 -0
  93. package/src/components/Message/Message.module.scss +224 -0
  94. package/src/components/Message/index.tsx +278 -0
  95. package/src/components/Popover/Popover.fragment.tsx +13 -4
  96. package/src/components/Popover/Popover.module.scss +33 -10
  97. package/src/components/Popover/index.tsx +9 -11
  98. package/src/components/Progress/Progress.fragment.tsx +1 -1
  99. package/src/components/Progress/Progress.module.scss +11 -11
  100. package/src/components/Progress/index.tsx +34 -7
  101. package/src/components/Prompt/Prompt.fragment.tsx +231 -0
  102. package/src/components/Prompt/Prompt.module.scss +243 -0
  103. package/src/components/Prompt/index.tsx +439 -0
  104. package/src/components/RadioGroup/RadioGroup.fragment.tsx +1 -1
  105. package/src/components/RadioGroup/RadioGroup.module.scss +10 -7
  106. package/src/components/RadioGroup/index.tsx +3 -4
  107. package/src/components/Select/Select.fragment.tsx +10 -1
  108. package/src/components/Select/Select.module.scss +8 -0
  109. package/src/components/Select/index.tsx +91 -12
  110. package/src/components/Separator/Separator.fragment.tsx +1 -1
  111. package/src/components/Separator/index.tsx +7 -3
  112. package/src/components/Sidebar/Sidebar.fragment.tsx +11 -2
  113. package/src/components/Sidebar/Sidebar.module.scss +91 -47
  114. package/src/components/Sidebar/index.tsx +57 -14
  115. package/src/components/Skeleton/Skeleton.fragment.tsx +6 -6
  116. package/src/components/Skeleton/Skeleton.module.scss +11 -0
  117. package/src/components/Slider/Slider.fragment.tsx +201 -0
  118. package/src/components/Slider/Slider.module.scss +87 -0
  119. package/src/components/Slider/index.tsx +88 -0
  120. package/src/components/Stack/Stack.fragment.tsx +194 -0
  121. package/src/components/Stack/Stack.module.scss +120 -0
  122. package/src/components/Stack/index.tsx +148 -0
  123. package/src/components/Table/Table.fragment.tsx +10 -3
  124. package/src/components/Table/Table.module.scss +57 -0
  125. package/src/components/Table/index.tsx +44 -6
  126. package/src/components/Tabs/Tabs.fragment.tsx +10 -1
  127. package/src/components/Tabs/Tabs.module.scss +25 -10
  128. package/src/components/Tabs/index.tsx +11 -8
  129. package/src/components/Text/Text.fragment.tsx +188 -0
  130. package/src/components/Text/Text.module.scss +82 -0
  131. package/src/components/Text/index.tsx +58 -0
  132. package/src/components/Textarea/Textarea.fragment.tsx +1 -1
  133. package/src/components/Textarea/index.tsx +3 -7
  134. package/src/components/Theme/Theme.fragment.tsx +128 -0
  135. package/src/components/Theme/ThemeToggle.module.scss +82 -0
  136. package/src/components/Theme/index.tsx +343 -0
  137. package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +182 -0
  138. package/src/components/ThinkingIndicator/ThinkingIndicator.module.scss +226 -0
  139. package/src/components/ThinkingIndicator/index.tsx +258 -0
  140. package/src/components/Toast/Toast.fragment.tsx +6 -6
  141. package/src/components/Toast/Toast.module.scss +16 -1
  142. package/src/components/Toast/index.tsx +27 -11
  143. package/src/components/Toggle/Toggle.fragment.tsx +1 -1
  144. package/src/components/Toggle/Toggle.module.scss +25 -10
  145. package/src/components/Toggle/index.tsx +12 -0
  146. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +207 -0
  147. package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
  148. package/src/components/ToggleGroup/index.tsx +144 -0
  149. package/src/components/Tooltip/Tooltip.fragment.tsx +3 -3
  150. package/src/components/Tooltip/Tooltip.module.scss +4 -4
  151. package/src/components/Tooltip/index.tsx +4 -2
  152. package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
  153. package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
  154. package/src/components/VisuallyHidden/index.tsx +29 -0
  155. package/src/index.ts +278 -3
  156. package/src/recipes/AIChat.recipe.ts +266 -0
  157. package/src/recipes/AppShell.recipe.ts +175 -0
  158. package/src/recipes/CardGrid.recipe.ts +6 -2
  159. package/src/recipes/ChatInterface.recipe.ts +87 -0
  160. package/src/recipes/CodeExamples.recipe.ts +66 -0
  161. package/src/recipes/DashboardLayout.recipe.ts +46 -12
  162. package/src/recipes/DashboardNav.recipe.ts +183 -0
  163. package/src/recipes/LoginForm.recipe.ts +8 -1
  164. package/src/recipes/SettingsPage.recipe.ts +37 -20
  165. package/src/styles/globals.scss +31 -0
  166. package/src/tokens/_computed.scss +212 -0
  167. package/src/tokens/_density.scss +171 -0
  168. package/src/tokens/_derive.scss +287 -0
  169. package/src/tokens/_index.scss +41 -0
  170. package/src/tokens/_mixins.scss +95 -1
  171. package/src/tokens/_palettes.scss +185 -0
  172. package/src/tokens/_radius.scss +107 -0
  173. package/src/tokens/_seeds.scss +59 -0
  174. package/src/tokens/_variables.scss +507 -101
  175. 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, '&amp;')
180
+ .replace(/</g, '&lt;')
181
+ .replace(/>/g, '&gt;')
182
+ .replace(/"/g, '&quot;')
183
+ .replace(/'/g, '&#039;');
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 };