@vvedantb/ui 0.0.0-stage → 0.1.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +124 -4
  3. package/src/ai-elements/artifact.tsx +99 -0
  4. package/src/ai-elements/attachments.tsx +366 -0
  5. package/src/ai-elements/chain-of-thought.tsx +212 -0
  6. package/src/ai-elements/code-block.tsx +478 -0
  7. package/src/ai-elements/conversation.tsx +157 -0
  8. package/src/ai-elements/image.tsx +25 -0
  9. package/src/ai-elements/inline-citation.tsx +252 -0
  10. package/src/ai-elements/message.tsx +269 -0
  11. package/src/ai-elements/model-selector.tsx +211 -0
  12. package/src/ai-elements/prompt-input.tsx +1018 -0
  13. package/src/ai-elements/queue.tsx +251 -0
  14. package/src/ai-elements/reasoning.tsx +178 -0
  15. package/src/ai-elements/shimmer.tsx +56 -0
  16. package/src/ai-elements/sources.tsx +77 -0
  17. package/src/ai-elements/speech-input.tsx +290 -0
  18. package/src/ai-elements/suggestion.tsx +191 -0
  19. package/src/css.d.ts +2 -0
  20. package/src/index.ts +96 -0
  21. package/src/motion/presets.ts +35 -0
  22. package/src/motion/presets.vibot.ts +105 -0
  23. package/src/ui/_menu-classes.ts +30 -0
  24. package/src/ui/_menu-classes.vibot.ts +33 -0
  25. package/src/ui/accordion.tsx +81 -0
  26. package/src/ui/accordion.verve.tsx +56 -0
  27. package/src/ui/badge.tsx +34 -0
  28. package/src/ui/breadcrumb.tsx +83 -0
  29. package/src/ui/button-group.tsx +84 -0
  30. package/src/ui/button.tsx +57 -0
  31. package/src/ui/card.tsx +27 -0
  32. package/src/ui/carousel.tsx +211 -0
  33. package/src/ui/checkbox.tsx +27 -0
  34. package/src/ui/clear-input.tsx +170 -0
  35. package/src/ui/clear-input.vibot.tsx +76 -0
  36. package/src/ui/clearInputDissolve.ts +214 -0
  37. package/src/ui/collapsible.tsx +7 -0
  38. package/src/ui/command.tsx +148 -0
  39. package/src/ui/context-menu.tsx +173 -0
  40. package/src/ui/dialog.tsx +149 -0
  41. package/src/ui/dropdown-menu.tsx +178 -0
  42. package/src/ui/hover-card.tsx +31 -0
  43. package/src/ui/input-group.tsx +162 -0
  44. package/src/ui/input-group.verve.tsx +150 -0
  45. package/src/ui/input.tsx +21 -0
  46. package/src/ui/kbd.tsx +18 -0
  47. package/src/ui/label.tsx +20 -0
  48. package/src/ui/labeled-switch-row.tsx +53 -0
  49. package/src/ui/modalTransition.ts +85 -0
  50. package/src/ui/pagination.tsx +47 -0
  51. package/src/ui/popover.tsx +33 -0
  52. package/src/ui/progress.tsx +25 -0
  53. package/src/ui/radix-select.tsx +202 -0
  54. package/src/ui/scroll-area.tsx +55 -0
  55. package/src/ui/scroll-area.verve.tsx +43 -0
  56. package/src/ui/search-input.tsx +29 -0
  57. package/src/ui/select.tsx +163 -0
  58. package/src/ui/separator.tsx +28 -0
  59. package/src/ui/sheet.tsx +87 -0
  60. package/src/ui/skeleton.tsx +18 -0
  61. package/src/ui/sonner.css +16 -0
  62. package/src/ui/sonner.tsx +47 -0
  63. package/src/ui/spinner.tsx +27 -0
  64. package/src/ui/surface-classes.ts +55 -0
  65. package/src/ui/switch.tsx +26 -0
  66. package/src/ui/table.tsx +123 -0
  67. package/src/ui/tabs.tsx +140 -0
  68. package/src/ui/tabsSliding.ts +56 -0
  69. package/src/ui/textarea.tsx +21 -0
  70. package/src/ui/time-picker.tsx +170 -0
  71. package/src/ui/tooltip.tsx +25 -0
  72. package/src/ui/visually-hidden.tsx +3 -0
  73. package/src/utils/cn.ts +16 -0
  74. package/src/utils/ref.ts +10 -0
  75. package/README.md +0 -3
@@ -0,0 +1,212 @@
1
+ "use client";
2
+ import type { TablerIcon } from "@tabler/icons-react";
3
+ import type { ComponentProps, ReactNode } from "react";
4
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
5
+ import {
6
+ IconBrain,
7
+ IconChevronDown,
8
+ IconPointFilled,
9
+ } from "@tabler/icons-react";
10
+ import { Badge } from "../ui/badge";
11
+ import {
12
+ Collapsible,
13
+ CollapsibleContent,
14
+ CollapsibleTrigger,
15
+ } from "../ui/collapsible";
16
+ import { cn } from "../utils/cn";
17
+ import { flatPanelSubtleClass } from "../ui/surface-classes";
18
+ import { createContext, useContext } from "react";
19
+ interface ChainOfThoughtContextValue {
20
+ isOpen: boolean;
21
+ setIsOpen: (open: boolean) => void;
22
+ }
23
+ const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
24
+ null,
25
+ );
26
+ const useChainOfThought = () => {
27
+ const context = useContext(ChainOfThoughtContext);
28
+ if (!context) {
29
+ throw new Error(
30
+ "ChainOfThought components must be used within ChainOfThought",
31
+ );
32
+ }
33
+ return context;
34
+ };
35
+ export type ChainOfThoughtProps = ComponentProps<"div"> & {
36
+ open?: boolean;
37
+ defaultOpen?: boolean;
38
+ onOpenChange?: (open: boolean) => void;
39
+ };
40
+ export function ChainOfThought({
41
+ className,
42
+ open,
43
+ defaultOpen = false,
44
+ onOpenChange,
45
+ children,
46
+ ...props
47
+ }: ChainOfThoughtProps) {
48
+ const [isOpen, setIsOpen] = useControllableState({
49
+ defaultProp: defaultOpen,
50
+ onChange: onOpenChange,
51
+ prop: open,
52
+ });
53
+ const chainOfThoughtContext = { isOpen, setIsOpen };
54
+ return (
55
+ <ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
56
+ <div className={cn("not-prose w-full space-y-4", className)} {...props}>
57
+ {children}
58
+ </div>
59
+ </ChainOfThoughtContext.Provider>
60
+ );
61
+ }
62
+ export type ChainOfThoughtHeaderProps = ComponentProps<
63
+ typeof CollapsibleTrigger
64
+ > & {
65
+ icon?: ReactNode;
66
+ };
67
+ export function ChainOfThoughtHeader({
68
+ className,
69
+ children,
70
+ icon,
71
+ ...props
72
+ }: ChainOfThoughtHeaderProps) {
73
+ const { isOpen, setIsOpen } = useChainOfThought();
74
+ return (
75
+ <Collapsible onOpenChange={setIsOpen} open={isOpen}>
76
+ <CollapsibleTrigger
77
+ className={cn(
78
+ "flex w-full items-center gap-2 text-muted text-sm transition-[color,transform] duration-150 ease-out active:scale-[0.99] hover:text-foreground",
79
+ className,
80
+ )}
81
+ {...props}
82
+ >
83
+ {icon ?? <IconBrain className="size-4" />}
84
+ <span className="flex-1 text-left">
85
+ {children ?? "Chain of Thought"}
86
+ </span>
87
+ <IconChevronDown
88
+ className={cn(
89
+ "size-4 transition-transform duration-150 ease-out",
90
+ isOpen ? "rotate-180" : "rotate-0",
91
+ )}
92
+ />
93
+ </CollapsibleTrigger>
94
+ </Collapsible>
95
+ );
96
+ }
97
+ export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
98
+ icon?: TablerIcon;
99
+ label: ReactNode;
100
+ description?: ReactNode;
101
+ status?: "complete" | "active" | "pending";
102
+ };
103
+ const stepStatusStyles = {
104
+ active: "text-foreground",
105
+ complete: "text-muted",
106
+ pending: "text-muted/50",
107
+ };
108
+ export function ChainOfThoughtStep({
109
+ className,
110
+ icon: Icon = IconPointFilled,
111
+ label,
112
+ description,
113
+ status = "complete",
114
+ children,
115
+ ...props
116
+ }: ChainOfThoughtStepProps) {
117
+ return (
118
+ <div
119
+ className={cn(
120
+ "flex gap-2 text-sm",
121
+ stepStatusStyles[status],
122
+ "fade-in-0 slide-in-from-top-2 animate-in duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]",
123
+ className,
124
+ )}
125
+ {...props}
126
+ >
127
+ <div className="relative mt-0.5">
128
+ <Icon className="size-4" />
129
+ <div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-separator" />
130
+ </div>
131
+ <div className="flex-1 space-y-2 overflow-hidden">
132
+ <div>{label}</div>
133
+ {description && <div className="text-muted text-xs">{description}</div>}
134
+ {children}
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+ export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
140
+ export function ChainOfThoughtSearchResults({
141
+ className,
142
+ ...props
143
+ }: ChainOfThoughtSearchResultsProps) {
144
+ return (
145
+ <div
146
+ className={cn("flex flex-wrap items-center gap-2", className)}
147
+ {...props}
148
+ />
149
+ );
150
+ }
151
+ export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
152
+ export function ChainOfThoughtSearchResult({
153
+ className,
154
+ children,
155
+ ...props
156
+ }: ChainOfThoughtSearchResultProps) {
157
+ return (
158
+ <Badge
159
+ className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
160
+ variant="secondary"
161
+ {...props}
162
+ >
163
+ {children}
164
+ </Badge>
165
+ );
166
+ }
167
+ export type ChainOfThoughtContentProps = ComponentProps<
168
+ typeof CollapsibleContent
169
+ >;
170
+ export function ChainOfThoughtContent({
171
+ className,
172
+ children,
173
+ ...props
174
+ }: ChainOfThoughtContentProps) {
175
+ const { isOpen } = useChainOfThought();
176
+ return (
177
+ <Collapsible open={isOpen}>
178
+ <CollapsibleContent
179
+ className={cn(
180
+ "mt-2 space-y-3 text-foreground outline-hidden",
181
+ className,
182
+ )}
183
+ {...props}
184
+ >
185
+ {children}
186
+ </CollapsibleContent>
187
+ </Collapsible>
188
+ );
189
+ }
190
+ export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
191
+ caption?: string;
192
+ };
193
+ export function ChainOfThoughtImage({
194
+ className,
195
+ children,
196
+ caption,
197
+ ...props
198
+ }: ChainOfThoughtImageProps) {
199
+ return (
200
+ <div className={cn("mt-2 space-y-2", className)} {...props}>
201
+ <div
202
+ className={cn(
203
+ flatPanelSubtleClass,
204
+ "relative flex max-h-88 items-center justify-center overflow-hidden rounded-lg p-3",
205
+ )}
206
+ >
207
+ {children}
208
+ </div>
209
+ {caption && <p className="text-muted text-xs">{caption}</p>}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,478 @@
1
+ "use client";
2
+ import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
3
+ import type {
4
+ BundledLanguage,
5
+ BundledTheme,
6
+ HighlighterGeneric,
7
+ ThemedToken,
8
+ } from "shiki";
9
+ import { Button } from "../ui/button";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "../ui/radix-select";
17
+ import { cn } from "../utils/cn";
18
+ import { flatPanelSubtleClass } from "../ui/surface-classes";
19
+ import { IconCheck, IconCopy } from "@tabler/icons-react";
20
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
21
+ import { bundledLanguages, createHighlighter } from "shiki";
22
+ const isBundledLanguage = (lang: string): lang is BundledLanguage =>
23
+ lang in bundledLanguages;
24
+ // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
25
+ // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
26
+ // eslint-disable-next-line no-bitwise -- shiki bitflag check
27
+ const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
28
+ // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
29
+ // eslint-disable-next-line no-bitwise -- shiki bitflag check
30
+ // oxlint-disable-next-line eslint(no-bitwise)
31
+ const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
32
+ const isUnderline = (fontStyle: number | undefined) =>
33
+ // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
34
+ // oxlint-disable-next-line eslint(no-bitwise)
35
+ fontStyle && fontStyle & 4;
36
+ // Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
37
+ interface KeyedToken {
38
+ token: ThemedToken;
39
+ key: string;
40
+ }
41
+ interface KeyedLine {
42
+ tokens: KeyedToken[];
43
+ key: string;
44
+ }
45
+ const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
46
+ lines.map((line, lineIdx) => ({
47
+ key: `line-${lineIdx}`,
48
+ tokens: line.map((token, tokenIdx) => ({
49
+ key: `line-${lineIdx}-${tokenIdx}`,
50
+ token,
51
+ })),
52
+ }));
53
+ // Token rendering component
54
+ const TokenSpan = ({ token }: { token: ThemedToken }) => (
55
+ <span
56
+ className="dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)!"
57
+ style={
58
+ {
59
+ backgroundColor: token.bgColor,
60
+ color: token.color,
61
+ fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
62
+ fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
63
+ textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
64
+ ...token.htmlStyle,
65
+ } as CSSProperties
66
+ }
67
+ >
68
+ {token.content}
69
+ </span>
70
+ );
71
+ // Line rendering component
72
+ const LineSpan = ({
73
+ keyedLine,
74
+ showLineNumbers,
75
+ }: {
76
+ keyedLine: KeyedLine;
77
+ showLineNumbers: boolean;
78
+ }) => (
79
+ <span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
80
+ {keyedLine.tokens.length === 0
81
+ ? "\n"
82
+ : keyedLine.tokens.map(({ token, key }) => (
83
+ <TokenSpan key={key} token={token} />
84
+ ))}
85
+ </span>
86
+ );
87
+ // Types
88
+ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
89
+ code: string;
90
+ language: string;
91
+ showLineNumbers?: boolean;
92
+ };
93
+ interface TokenizedCode {
94
+ tokens: ThemedToken[][];
95
+ fg: string;
96
+ bg: string;
97
+ }
98
+ interface CodeBlockContextType {
99
+ code: string;
100
+ }
101
+ // Context
102
+ const CodeBlockContext = createContext<CodeBlockContextType>({
103
+ code: "",
104
+ });
105
+ // Highlighter cache (singleton per language)
106
+ const highlighterCache = new Map<
107
+ string,
108
+ Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
109
+ >();
110
+ // Token cache
111
+ const tokensCache = new Map<string, TokenizedCode>();
112
+ // Subscribers for async token updates
113
+ const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
114
+ const getTokensCacheKey = (code: string, language: BundledLanguage) => {
115
+ const start = code.slice(0, 100);
116
+ const end = code.length > 100 ? code.slice(-100) : "";
117
+ return `${language}:${code.length}:${start}:${end}`;
118
+ };
119
+ const getHighlighter = (
120
+ language: BundledLanguage,
121
+ ): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
122
+ const cached = highlighterCache.get(language);
123
+ if (cached) {
124
+ return cached;
125
+ }
126
+ const highlighterPromise = createHighlighter({
127
+ langs: [language],
128
+ themes: ["github-light", "github-dark"],
129
+ });
130
+ highlighterCache.set(language, highlighterPromise);
131
+ return highlighterPromise;
132
+ };
133
+ // Create raw tokens for immediate display while highlighting loads
134
+ const createRawTokens = (code: string): TokenizedCode => ({
135
+ bg: "transparent",
136
+ fg: "inherit",
137
+ tokens: code.split("\n").map((line) =>
138
+ line === ""
139
+ ? []
140
+ : [
141
+ {
142
+ color: "inherit",
143
+ content: line,
144
+ } as ThemedToken,
145
+ ],
146
+ ),
147
+ });
148
+ // Synchronous highlight with callback for async results
149
+ export const highlightCode = (
150
+ code: string,
151
+ language: string,
152
+ // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
153
+ callback?: (result: TokenizedCode) => void,
154
+ ): TokenizedCode | null => {
155
+ if (!isBundledLanguage(language)) return null;
156
+ const tokensCacheKey = getTokensCacheKey(code, language);
157
+ // Return cached result if available
158
+ const cached = tokensCache.get(tokensCacheKey);
159
+ if (cached) {
160
+ return cached;
161
+ }
162
+ // Subscribe callback if provided
163
+ if (callback) {
164
+ if (!subscribers.has(tokensCacheKey)) {
165
+ subscribers.set(tokensCacheKey, new Set());
166
+ }
167
+ subscribers.get(tokensCacheKey)?.add(callback);
168
+ }
169
+ // Start highlighting in background - fire-and-forget async pattern
170
+ getHighlighter(language)
171
+ // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
172
+ .then((highlighter) => {
173
+ const availableLangs = highlighter.getLoadedLanguages();
174
+ const langToUse = availableLangs.includes(language) ? language : "text";
175
+ const result = highlighter.codeToTokens(code, {
176
+ lang: langToUse,
177
+ themes: {
178
+ dark: "github-dark",
179
+ light: "github-light",
180
+ },
181
+ });
182
+ const tokenized: TokenizedCode = {
183
+ bg: result.bg ?? "transparent",
184
+ fg: result.fg ?? "inherit",
185
+ tokens: result.tokens,
186
+ };
187
+ // Cache the result
188
+ tokensCache.set(tokensCacheKey, tokenized);
189
+ // Notify all subscribers
190
+ const subs = subscribers.get(tokensCacheKey);
191
+ if (subs) {
192
+ for (const sub of subs) {
193
+ sub(tokenized);
194
+ }
195
+ subscribers.delete(tokensCacheKey);
196
+ }
197
+ })
198
+ // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
199
+ .catch((error) => {
200
+ console.error("Failed to highlight code:", error);
201
+ subscribers.delete(tokensCacheKey);
202
+ });
203
+ return null;
204
+ };
205
+ // Line number styles using CSS counters
206
+ const LINE_NUMBER_CLASSES = cn(
207
+ "block",
208
+ "before:content-[counter(line)]",
209
+ "before:inline-block",
210
+ "before:[counter-increment:line]",
211
+ "before:w-8",
212
+ "before:mr-4",
213
+ "before:text-right",
214
+ "before:text-muted/50",
215
+ "before:font-mono",
216
+ "before:select-none",
217
+ );
218
+ function CodeBlockBody({
219
+ tokenized,
220
+ showLineNumbers,
221
+ className,
222
+ }: {
223
+ tokenized: TokenizedCode;
224
+ showLineNumbers: boolean;
225
+ className?: string;
226
+ }) {
227
+ const preStyle = {
228
+ backgroundColor: tokenized.bg,
229
+ color: tokenized.fg,
230
+ };
231
+ const keyedLines = addKeysToTokens(tokenized.tokens);
232
+ return (
233
+ <pre
234
+ className={cn(
235
+ "dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)! m-0 p-4 text-sm",
236
+ className,
237
+ )}
238
+ style={preStyle}
239
+ >
240
+ <code
241
+ className={cn(
242
+ "font-mono text-sm",
243
+ showLineNumbers && "[counter-increment:line_0] [counter-reset:line]",
244
+ )}
245
+ >
246
+ {keyedLines.map((keyedLine) => (
247
+ <LineSpan
248
+ key={keyedLine.key}
249
+ keyedLine={keyedLine}
250
+ showLineNumbers={showLineNumbers}
251
+ />
252
+ ))}
253
+ </code>
254
+ </pre>
255
+ );
256
+ }
257
+ export const CodeBlockContainer = ({
258
+ className,
259
+ language,
260
+ style,
261
+ ...props
262
+ }: HTMLAttributes<HTMLDivElement> & {
263
+ language: string;
264
+ }) => (
265
+ <div
266
+ className={cn(
267
+ "relative w-full overflow-hidden rounded-lg bg-surface-secondary/60 text-foreground",
268
+ className,
269
+ )}
270
+ data-language={language}
271
+ style={{
272
+ containIntrinsicSize: "auto 200px",
273
+ contentVisibility: "auto",
274
+ ...style,
275
+ }}
276
+ {...props}
277
+ />
278
+ );
279
+ export const CodeBlockHeader = ({
280
+ children,
281
+ className,
282
+ ...props
283
+ }: HTMLAttributes<HTMLDivElement>) => (
284
+ <div
285
+ className={cn(
286
+ cn(
287
+ flatPanelSubtleClass,
288
+ "flex items-center justify-between px-3 py-2 text-muted text-xs",
289
+ ),
290
+ className,
291
+ )}
292
+ {...props}
293
+ >
294
+ {children}
295
+ </div>
296
+ );
297
+ export const CodeBlockTitle = ({
298
+ children,
299
+ className,
300
+ ...props
301
+ }: HTMLAttributes<HTMLDivElement>) => (
302
+ <div className={cn("flex items-center gap-2", className)} {...props}>
303
+ {children}
304
+ </div>
305
+ );
306
+ export const CodeBlockFilename = ({
307
+ children,
308
+ className,
309
+ ...props
310
+ }: HTMLAttributes<HTMLSpanElement>) => (
311
+ <span className={cn("font-mono", className)} {...props}>
312
+ {children}
313
+ </span>
314
+ );
315
+ export const CodeBlockActions = ({
316
+ children,
317
+ className,
318
+ ...props
319
+ }: HTMLAttributes<HTMLDivElement>) => (
320
+ <div
321
+ className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
322
+ {...props}
323
+ >
324
+ {children}
325
+ </div>
326
+ );
327
+ export const CodeBlockContent = ({
328
+ code,
329
+ language,
330
+ showLineNumbers = false,
331
+ }: {
332
+ code: string;
333
+ language: string;
334
+ showLineNumbers?: boolean;
335
+ }) => {
336
+ // Memoized raw tokens for immediate display
337
+ const rawTokens = createRawTokens(code);
338
+ // Try to get cached result synchronously, otherwise use raw tokens
339
+ const [tokenized, setTokenized] = useState<TokenizedCode>(
340
+ () => highlightCode(code, language) ?? rawTokens,
341
+ );
342
+ useEffect(() => {
343
+ let cancelled = false;
344
+ // Reset to raw tokens when code changes (shows current code, not stale tokens)
345
+ setTokenized(highlightCode(code, language) ?? rawTokens);
346
+ // Subscribe to async highlighting result
347
+ highlightCode(code, language, (result) => {
348
+ if (!cancelled) {
349
+ setTokenized(result);
350
+ }
351
+ });
352
+ return () => {
353
+ cancelled = true;
354
+ };
355
+ }, [code, language, rawTokens]);
356
+ return (
357
+ <div className="relative overflow-auto">
358
+ <CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
359
+ </div>
360
+ );
361
+ };
362
+ export const CodeBlock = ({
363
+ code,
364
+ language,
365
+ showLineNumbers = false,
366
+ className,
367
+ children,
368
+ ...props
369
+ }: CodeBlockProps) => {
370
+ const contextValue = { code };
371
+ return (
372
+ <CodeBlockContext.Provider value={contextValue}>
373
+ <CodeBlockContainer className={className} language={language} {...props}>
374
+ {children}
375
+ <CodeBlockContent
376
+ code={code}
377
+ language={language}
378
+ showLineNumbers={showLineNumbers}
379
+ />
380
+ </CodeBlockContainer>
381
+ </CodeBlockContext.Provider>
382
+ );
383
+ };
384
+ export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
385
+ onCopy?: () => void;
386
+ onError?: (error: Error) => void;
387
+ timeout?: number;
388
+ };
389
+ export const CodeBlockCopyButton = ({
390
+ onCopy,
391
+ onError,
392
+ timeout = 2000,
393
+ children,
394
+ className,
395
+ ...props
396
+ }: CodeBlockCopyButtonProps) => {
397
+ const [isCopied, setIsCopied] = useState(false);
398
+ const timeoutRef = useRef<number>(0);
399
+ const { code } = useContext(CodeBlockContext);
400
+ const copyToClipboard = async () => {
401
+ if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
402
+ onError?.(new Error("Clipboard API not available"));
403
+ return;
404
+ }
405
+ try {
406
+ if (!isCopied) {
407
+ await navigator.clipboard.writeText(code);
408
+ setIsCopied(true);
409
+ onCopy?.();
410
+ timeoutRef.current = window.setTimeout(
411
+ () => setIsCopied(false),
412
+ timeout,
413
+ );
414
+ }
415
+ } catch (error) {
416
+ onError?.(error as Error);
417
+ }
418
+ };
419
+ useEffect(
420
+ () => () => {
421
+ window.clearTimeout(timeoutRef.current);
422
+ },
423
+ [],
424
+ );
425
+ const Icon = isCopied ? IconCheck : IconCopy;
426
+ return (
427
+ <Button
428
+ className={cn("shrink-0", className)}
429
+ onClick={copyToClipboard}
430
+ size="icon"
431
+ variant="ghost"
432
+ {...props}
433
+ >
434
+ {children ?? <Icon size={14} />}
435
+ </Button>
436
+ );
437
+ };
438
+ export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
439
+ export const CodeBlockLanguageSelector = (
440
+ props: CodeBlockLanguageSelectorProps,
441
+ ) => <Select {...props} />;
442
+ export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
443
+ typeof SelectTrigger
444
+ >;
445
+ export const CodeBlockLanguageSelectorTrigger = ({
446
+ className,
447
+ ...props
448
+ }: CodeBlockLanguageSelectorTriggerProps) => (
449
+ <SelectTrigger
450
+ className={cn(
451
+ "h-7 border-none bg-transparent px-2 text-xs shadow-none",
452
+ className,
453
+ )}
454
+ size="sm"
455
+ {...props}
456
+ />
457
+ );
458
+ export type CodeBlockLanguageSelectorValueProps = ComponentProps<
459
+ typeof SelectValue
460
+ >;
461
+ export const CodeBlockLanguageSelectorValue = (
462
+ props: CodeBlockLanguageSelectorValueProps,
463
+ ) => <SelectValue {...props} />;
464
+ export type CodeBlockLanguageSelectorContentProps = ComponentProps<
465
+ typeof SelectContent
466
+ >;
467
+ export const CodeBlockLanguageSelectorContent = ({
468
+ align = "end",
469
+ ...props
470
+ }: CodeBlockLanguageSelectorContentProps) => (
471
+ <SelectContent align={align} {...props} />
472
+ );
473
+ export type CodeBlockLanguageSelectorItemProps = ComponentProps<
474
+ typeof SelectItem
475
+ >;
476
+ export const CodeBlockLanguageSelectorItem = (
477
+ props: CodeBlockLanguageSelectorItemProps,
478
+ ) => <SelectItem {...props} />;