@jogak/ui 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
@@ -0,0 +1,739 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { ReactElement, CSSProperties } from 'react'
3
+ import { Highlight, themes } from 'prism-react-renderer'
4
+ import type { PrismTheme } from 'prism-react-renderer'
5
+ import { reactAdapter, useEntry } from '@jogak/react'
6
+ import type { UseEntryState } from '@jogak/react'
7
+ import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
8
+ import { Controls } from '../Controls/index.js'
9
+ import { Actions } from '../Actions/index.js'
10
+
11
+ export interface PreviewProps {
12
+ readonly entryId: string
13
+ readonly jogakName: string | null
14
+ readonly overrideArgs: Readonly<Record<string, unknown>>
15
+ readonly onArgChange: (key: string, value: unknown) => void
16
+ readonly onReset: () => void
17
+ readonly codeTheme: string
18
+ /**
19
+ * URL deep link `?entry=<id>` (jogak 미지정) 케이스에서 entry hydrate 후
20
+ * 첫 jogak로 자동 보정하기 위한 콜백. 부모가 selectedJogakName / URL을 갱신.
21
+ */
22
+ readonly onResolveJogak?: (entryId: string, jogakName: string) => void
23
+ }
24
+
25
+ type ViewportKey = 'mobile' | 'tablet' | 'desktop'
26
+ type BgMode = 'white' | 'dark' | 'transparent'
27
+
28
+ const VIEWPORT_WIDTHS: Record<ViewportKey, number | 'none'> = {
29
+ mobile: 375,
30
+ tablet: 768,
31
+ desktop: 'none',
32
+ }
33
+
34
+ const VIEWPORT_LABELS: Record<ViewportKey, string> = {
35
+ mobile: 'Mobile',
36
+ tablet: 'Tablet',
37
+ desktop: 'Desktop',
38
+ }
39
+
40
+ const BG_STYLES: Record<BgMode, CSSProperties> = {
41
+ white: { background: '#ffffff' },
42
+ dark: { background: '#1f2937' },
43
+ transparent: {
44
+ backgroundImage: [
45
+ 'linear-gradient(45deg, #e2e8f0 25%, transparent 25%)',
46
+ 'linear-gradient(-45deg, #e2e8f0 25%, transparent 25%)',
47
+ 'linear-gradient(45deg, transparent 75%, #e2e8f0 75%)',
48
+ 'linear-gradient(-45deg, transparent 75%, #e2e8f0 75%)',
49
+ ].join(', '),
50
+ backgroundSize: '16px 16px',
51
+ backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
52
+ backgroundColor: '#ffffff',
53
+ },
54
+ }
55
+
56
+ /** 캔버스 영역 minHeight — loading/ready 사이 layout shift 방지 (계약 §10). */
57
+ const CANVAS_MIN_HEIGHT = 320
58
+
59
+ function resolvePrismTheme(name: string): PrismTheme {
60
+ const map = themes as Record<string, PrismTheme | undefined>
61
+ return map[name] ?? themes.vsDark
62
+ }
63
+
64
+ /**
65
+ * Preview — `useEntry(entryId)`의 status에 따라 분기 (계약 §5.4).
66
+ *
67
+ * - `loading` → 메타로 헤더(title, jogak 이름)만 표시, 캔버스에 skeleton
68
+ * - `ready` → 현행 렌더 (entry.jogaks/component 사용)
69
+ * - `error` → 에러 패널
70
+ * - `unknown` → "Entry not found" placeholder
71
+ *
72
+ * Layout shift 방지를 위해 캔버스 영역 minHeight 유지.
73
+ */
74
+ export function Preview({
75
+ entryId,
76
+ jogakName,
77
+ overrideArgs,
78
+ onArgChange,
79
+ onReset,
80
+ codeTheme,
81
+ onResolveJogak,
82
+ }: PreviewProps): ReactElement {
83
+ const state = useEntry(entryId)
84
+ const [viewport, setViewport] = useState<ViewportKey>('desktop')
85
+ const [bgMode, setBgMode] = useState<BgMode>('white')
86
+ const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
87
+
88
+ const prismTheme = resolvePrismTheme(codeTheme)
89
+
90
+ // ── unknown ───────────────────────────────────────────────
91
+ if (state.status === 'unknown') {
92
+ return (
93
+ <div data-testid="preview-not-found" style={{ padding: 24, color: '#ef4444' }}>
94
+ Entry not found: {entryId}
95
+ </div>
96
+ )
97
+ }
98
+
99
+ // ── error ─────────────────────────────────────────────────
100
+ if (state.status === 'error') {
101
+ return (
102
+ <div
103
+ data-testid="preview-error"
104
+ style={{
105
+ padding: 24,
106
+ color: '#b91c1c',
107
+ background: '#fef2f2',
108
+ height: '100%',
109
+ display: 'flex',
110
+ flexDirection: 'column',
111
+ gap: 12,
112
+ alignItems: 'flex-start',
113
+ }}
114
+ >
115
+ <div style={{ fontWeight: 600 }}>Failed to load entry: {entryId}</div>
116
+ <pre
117
+ style={{
118
+ margin: 0,
119
+ padding: 12,
120
+ background: '#fff',
121
+ border: '1px solid #fecaca',
122
+ borderRadius: 6,
123
+ fontSize: 12,
124
+ whiteSpace: 'pre-wrap',
125
+ maxWidth: '100%',
126
+ }}
127
+ >
128
+ {state.error.message}
129
+ </pre>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ // ── loading ───────────────────────────────────────────────
135
+ if (state.status === 'loading') {
136
+ return (
137
+ <LoadingFrame
138
+ meta={state.meta}
139
+ jogakName={jogakName}
140
+ viewport={viewport}
141
+ bgMode={bgMode}
142
+ onViewportChange={setViewport}
143
+ onBgModeChange={setBgMode}
144
+ />
145
+ )
146
+ }
147
+
148
+ // ── ready ─────────────────────────────────────────────────
149
+ return (
150
+ <ReadyFrame
151
+ entry={state.entry}
152
+ jogakName={jogakName}
153
+ overrideArgs={overrideArgs}
154
+ onArgChange={onArgChange}
155
+ onReset={onReset}
156
+ onResolveJogak={onResolveJogak}
157
+ viewport={viewport}
158
+ bgMode={bgMode}
159
+ bottomTab={bottomTab}
160
+ onViewportChange={setViewport}
161
+ onBgModeChange={setBgMode}
162
+ onBottomTabChange={setBottomTab}
163
+ prismTheme={prismTheme}
164
+ />
165
+ )
166
+ }
167
+
168
+ // ── LoadingFrame ──────────────────────────────────────────
169
+
170
+ interface LoadingFrameProps {
171
+ readonly meta: RegistryEntryMeta
172
+ readonly jogakName: string | null
173
+ readonly viewport: ViewportKey
174
+ readonly bgMode: BgMode
175
+ readonly onViewportChange: (vp: ViewportKey) => void
176
+ readonly onBgModeChange: (bg: BgMode) => void
177
+ }
178
+
179
+ function LoadingFrame({
180
+ meta,
181
+ jogakName,
182
+ viewport,
183
+ bgMode,
184
+ onViewportChange,
185
+ onBgModeChange,
186
+ }: LoadingFrameProps): ReactElement {
187
+ const displayJogak = jogakName ?? meta.jogakNames[0] ?? '...'
188
+ const maxWidth = VIEWPORT_WIDTHS[viewport]
189
+
190
+ return (
191
+ <div
192
+ data-testid="preview-loading"
193
+ style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
194
+ >
195
+ <Toolbar
196
+ title={meta.title}
197
+ jogakName={displayJogak}
198
+ viewport={viewport}
199
+ bgMode={bgMode}
200
+ onViewportChange={onViewportChange}
201
+ onBgModeChange={onBgModeChange}
202
+ showReset={false}
203
+ onReset={() => {}}
204
+ />
205
+ <div
206
+ style={{
207
+ flex: 1,
208
+ minHeight: CANVAS_MIN_HEIGHT,
209
+ overflow: 'auto',
210
+ ...BG_STYLES[bgMode],
211
+ }}
212
+ >
213
+ <div
214
+ style={{
215
+ maxWidth: maxWidth === 'none' ? '100%' : maxWidth,
216
+ margin: '0 auto',
217
+ padding: 24,
218
+ }}
219
+ >
220
+ <div
221
+ style={{
222
+ border: '1px dashed #e5e7eb',
223
+ borderRadius: 8,
224
+ padding: 16,
225
+ minHeight: CANVAS_MIN_HEIGHT - 64,
226
+ display: 'flex',
227
+ alignItems: 'center',
228
+ justifyContent: 'center',
229
+ color: '#9ca3af',
230
+ fontSize: 13,
231
+ background:
232
+ 'linear-gradient(90deg, rgba(229,231,235,0) 0%, rgba(229,231,235,0.45) 50%, rgba(229,231,235,0) 100%)',
233
+ backgroundSize: '200% 100%',
234
+ animation: 'jogakSkeleton 1.4s ease-in-out infinite',
235
+ }}
236
+ >
237
+ Loading {meta.title}…
238
+ </div>
239
+ </div>
240
+ </div>
241
+ <style>
242
+ {`@keyframes jogakSkeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}
243
+ </style>
244
+ </div>
245
+ )
246
+ }
247
+
248
+ // ── ReadyFrame ────────────────────────────────────────────
249
+
250
+ interface ReadyFrameProps {
251
+ readonly entry: RegistryEntry
252
+ readonly jogakName: string | null
253
+ readonly overrideArgs: Readonly<Record<string, unknown>>
254
+ readonly onArgChange: (key: string, value: unknown) => void
255
+ readonly onReset: () => void
256
+ readonly onResolveJogak: ((entryId: string, jogakName: string) => void) | undefined
257
+ readonly viewport: ViewportKey
258
+ readonly bgMode: BgMode
259
+ readonly bottomTab: 'controls' | 'actions'
260
+ readonly onViewportChange: (vp: ViewportKey) => void
261
+ readonly onBgModeChange: (bg: BgMode) => void
262
+ readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
263
+ readonly prismTheme: PrismTheme
264
+ }
265
+
266
+ function ReadyFrame({
267
+ entry,
268
+ jogakName,
269
+ overrideArgs,
270
+ onArgChange,
271
+ onReset,
272
+ onResolveJogak,
273
+ viewport,
274
+ bgMode,
275
+ bottomTab,
276
+ onViewportChange,
277
+ onBgModeChange,
278
+ onBottomTabChange,
279
+ prismTheme,
280
+ }: ReadyFrameProps): ReactElement {
281
+ // jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
282
+ const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
283
+
284
+ useEffect(() => {
285
+ if (jogakName === null && resolvedJogakName !== null && onResolveJogak !== undefined) {
286
+ onResolveJogak(entry.id, resolvedJogakName)
287
+ }
288
+ }, [jogakName, resolvedJogakName, entry.id, onResolveJogak])
289
+
290
+ if (resolvedJogakName === null) {
291
+ return (
292
+ <div style={{ padding: 24, color: '#ef4444' }}>
293
+ Entry has no jogaks: {entry.id}
294
+ </div>
295
+ )
296
+ }
297
+
298
+ const jogak = entry.jogaks.find((j) => j.name === resolvedJogakName)
299
+ if (jogak === undefined) {
300
+ return (
301
+ <div style={{ padding: 24, color: '#ef4444' }}>
302
+ Jogak not found: {resolvedJogakName}
303
+ </div>
304
+ )
305
+ }
306
+
307
+ const baseArgs = jogak.args ?? {}
308
+ const mergedArgs = { ...baseArgs, ...overrideArgs }
309
+ const mergedArgTypes: Readonly<Record<string, ArgType>> = {
310
+ ...(entry.meta.argTypes ?? {}),
311
+ ...(jogak.argTypes ?? {}),
312
+ }
313
+ const hasOverrides = Object.keys(overrideArgs).length > 0
314
+ const maxWidth = VIEWPORT_WIDTHS[viewport]
315
+
316
+ return (
317
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
318
+ <Toolbar
319
+ title={entry.title}
320
+ jogakName={jogak.name}
321
+ viewport={viewport}
322
+ bgMode={bgMode}
323
+ onViewportChange={onViewportChange}
324
+ onBgModeChange={onBgModeChange}
325
+ showReset={hasOverrides}
326
+ onReset={onReset}
327
+ />
328
+
329
+ {/* ── 캔버스 ───────────────────────────────────────── */}
330
+ <div
331
+ style={{
332
+ flex: 1,
333
+ minHeight: CANVAS_MIN_HEIGHT,
334
+ overflow: 'auto',
335
+ ...BG_STYLES[bgMode],
336
+ }}
337
+ >
338
+ <div
339
+ style={{
340
+ maxWidth: maxWidth === 'none' ? '100%' : maxWidth,
341
+ margin: '0 auto',
342
+ padding: 24,
343
+ }}
344
+ >
345
+ <JogakRenderer
346
+ key={`${entry.id}/${jogak.name}`}
347
+ entry={entry}
348
+ args={mergedArgs}
349
+ source={entry.source}
350
+ theme={prismTheme}
351
+ />
352
+ </div>
353
+ </div>
354
+
355
+ {/* ── 컨트롤/액션 패널 ──────────────────────────────── */}
356
+ <div
357
+ style={{
358
+ height: 260,
359
+ flexShrink: 0,
360
+ display: 'flex',
361
+ flexDirection: 'column',
362
+ borderTop: '2px solid #e5e7eb',
363
+ }}
364
+ >
365
+ <div
366
+ role="tablist"
367
+ style={{
368
+ display: 'flex',
369
+ gap: 4,
370
+ padding: '4px 12px 0',
371
+ background: '#fff',
372
+ borderBottom: '1px solid #e5e7eb',
373
+ flexShrink: 0,
374
+ }}
375
+ >
376
+ {(['controls', 'actions'] as const).map((tab) => {
377
+ const active = bottomTab === tab
378
+ return (
379
+ <button
380
+ key={tab}
381
+ type="button"
382
+ role="tab"
383
+ aria-selected={active}
384
+ onClick={() => { onBottomTabChange(tab) }}
385
+ style={{
386
+ padding: '6px 14px',
387
+ fontSize: 12,
388
+ fontWeight: active ? 600 : 500,
389
+ color: active ? '#111827' : '#6b7280',
390
+ background: 'transparent',
391
+ border: 'none',
392
+ borderBottom: active ? '2px solid #2563eb' : '2px solid transparent',
393
+ marginBottom: -1,
394
+ cursor: 'pointer',
395
+ textTransform: 'capitalize',
396
+ }}
397
+ >
398
+ {tab}
399
+ </button>
400
+ )
401
+ })}
402
+ </div>
403
+
404
+ <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
405
+ {bottomTab === 'controls' ? (
406
+ <Controls
407
+ args={mergedArgs}
408
+ argTypes={mergedArgTypes}
409
+ onArgChange={onArgChange}
410
+ />
411
+ ) : (
412
+ <Actions />
413
+ )}
414
+ </div>
415
+ </div>
416
+ </div>
417
+ )
418
+ }
419
+
420
+ // ── Toolbar (loading / ready 공용) ─────────────────────────
421
+
422
+ interface ToolbarProps {
423
+ readonly title: string
424
+ readonly jogakName: string
425
+ readonly viewport: ViewportKey
426
+ readonly bgMode: BgMode
427
+ readonly onViewportChange: (vp: ViewportKey) => void
428
+ readonly onBgModeChange: (bg: BgMode) => void
429
+ readonly showReset: boolean
430
+ readonly onReset: () => void
431
+ }
432
+
433
+ function Toolbar({
434
+ title,
435
+ jogakName,
436
+ viewport,
437
+ bgMode,
438
+ onViewportChange,
439
+ onBgModeChange,
440
+ showReset,
441
+ onReset,
442
+ }: ToolbarProps): ReactElement {
443
+ return (
444
+ <div
445
+ style={{
446
+ display: 'flex',
447
+ alignItems: 'center',
448
+ gap: 10,
449
+ padding: '7px 14px',
450
+ borderBottom: '1px solid #e5e7eb',
451
+ background: '#fff',
452
+ flexShrink: 0,
453
+ }}
454
+ >
455
+ <div style={{ flex: 1, fontSize: 13 }}>
456
+ <span style={{ color: '#9ca3af' }}>{title}</span>
457
+ <span style={{ color: '#d1d5db', margin: '0 6px' }}>/</span>
458
+ <span style={{ color: '#111827', fontWeight: 600 }}>{jogakName}</span>
459
+ </div>
460
+
461
+ {/* 뷰포트 토글 */}
462
+ <div
463
+ style={{
464
+ display: 'flex',
465
+ gap: 2,
466
+ background: '#f3f4f6',
467
+ borderRadius: 6,
468
+ padding: 2,
469
+ }}
470
+ >
471
+ {(['mobile', 'tablet', 'desktop'] as const).map((vp) => (
472
+ <button
473
+ key={vp}
474
+ type="button"
475
+ onClick={() => { onViewportChange(vp) }}
476
+ aria-pressed={viewport === vp}
477
+ style={{
478
+ padding: '3px 9px',
479
+ fontSize: 12,
480
+ border: 'none',
481
+ borderRadius: 4,
482
+ cursor: 'pointer',
483
+ background: viewport === vp ? '#fff' : 'transparent',
484
+ color: viewport === vp ? '#111827' : '#6b7280',
485
+ fontWeight: viewport === vp ? 600 : 400,
486
+ boxShadow: viewport === vp ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
487
+ transition: 'all 0.1s',
488
+ }}
489
+ >
490
+ {VIEWPORT_LABELS[vp]}
491
+ </button>
492
+ ))}
493
+ </div>
494
+
495
+ {/* 배경 토글 */}
496
+ <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
497
+ {(['white', 'dark', 'transparent'] as const).map((bg) => (
498
+ <button
499
+ key={bg}
500
+ type="button"
501
+ onClick={() => { onBgModeChange(bg) }}
502
+ aria-pressed={bgMode === bg}
503
+ aria-label={`${bg} background`}
504
+ style={{
505
+ width: 20,
506
+ height: 20,
507
+ borderRadius: 4,
508
+ border: bgMode === bg ? '2px solid #2563eb' : '2px solid #d1d5db',
509
+ cursor: 'pointer',
510
+ padding: 0,
511
+ flexShrink: 0,
512
+ ...BG_STYLES[bg],
513
+ }}
514
+ />
515
+ ))}
516
+ </div>
517
+
518
+ {/* 리셋 */}
519
+ {showReset && (
520
+ <button
521
+ type="button"
522
+ onClick={onReset}
523
+ style={{
524
+ padding: '3px 10px',
525
+ fontSize: 12,
526
+ border: '1px solid #d1d5db',
527
+ borderRadius: 4,
528
+ background: '#fff',
529
+ cursor: 'pointer',
530
+ color: '#374151',
531
+ }}
532
+ >
533
+ Reset
534
+ </button>
535
+ )}
536
+ </div>
537
+ )
538
+ }
539
+
540
+ // ── JogakRenderer ─────────────────────────────────────────
541
+
542
+ interface JogakRendererProps {
543
+ readonly entry: RegistryEntry
544
+ readonly args: Readonly<Record<string, unknown>>
545
+ readonly source: string | undefined
546
+ readonly theme: PrismTheme
547
+ }
548
+
549
+ function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): ReactElement {
550
+ const containerRef = useRef<HTMLDivElement>(null)
551
+ const [showCode, setShowCode] = useState(false)
552
+
553
+ useEffect(() => {
554
+ const container = containerRef.current
555
+ if (container === null) return
556
+ reactAdapter.render(entry, args, container)
557
+ return () => { reactAdapter.unmount(container) }
558
+ // eslint-disable-next-line react-hooks/exhaustive-deps
559
+ }, [entry])
560
+
561
+ useEffect(() => {
562
+ const container = containerRef.current
563
+ if (container === null) return
564
+ reactAdapter.render(entry, args, container)
565
+ }, [entry, args])
566
+
567
+ return (
568
+ <div>
569
+ {/* preview-content 영역 + 토글 버튼 */}
570
+ <div style={{ position: 'relative' }}>
571
+ <div
572
+ ref={containerRef}
573
+ data-testid="preview-content"
574
+ style={{
575
+ border: '1px dashed #e5e7eb',
576
+ borderRadius: 8,
577
+ padding: 16,
578
+ paddingBottom: 36,
579
+ }}
580
+ />
581
+ <button
582
+ type="button"
583
+ onClick={() => { setShowCode((v) => !v) }}
584
+ aria-pressed={showCode}
585
+ aria-label={showCode ? 'Hide source code' : 'Show source code'}
586
+ style={{
587
+ position: 'absolute',
588
+ bottom: 8,
589
+ right: 8,
590
+ padding: '4px 9px',
591
+ fontSize: 11,
592
+ fontFamily:
593
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
594
+ fontWeight: 600,
595
+ letterSpacing: '0.02em',
596
+ background: showCode ? '#2563eb' : '#1e293b',
597
+ color: '#fff',
598
+ border: 'none',
599
+ borderRadius: 5,
600
+ cursor: 'pointer',
601
+ boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
602
+ transition: 'background 0.15s',
603
+ }}
604
+ >
605
+ {'</>'}
606
+ </button>
607
+ </div>
608
+
609
+ {/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
610
+ {showCode && (
611
+ <div
612
+ style={{
613
+ marginTop: 8,
614
+ borderRadius: 8,
615
+ overflow: 'hidden',
616
+ height: 320,
617
+ boxShadow: '0 0 0 1px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.12)',
618
+ }}
619
+ >
620
+ <SourceViewer source={source} theme={theme} />
621
+ </div>
622
+ )}
623
+ </div>
624
+ )
625
+ }
626
+
627
+ // ── SourceViewer ──────────────────────────────────────────
628
+
629
+ interface SourceViewerProps {
630
+ readonly source: string | undefined
631
+ readonly theme: PrismTheme
632
+ }
633
+
634
+ function SourceViewer({ source, theme }: SourceViewerProps): ReactElement {
635
+ const [copied, setCopied] = useState(false)
636
+ const bgColor = (theme.plain.backgroundColor as string | undefined) ?? '#1e293b'
637
+
638
+ if (source === undefined) {
639
+ return (
640
+ <div
641
+ style={{
642
+ height: '100%',
643
+ display: 'flex',
644
+ alignItems: 'center',
645
+ justifyContent: 'center',
646
+ background: bgColor,
647
+ color: '#94a3b8',
648
+ fontSize: 13,
649
+ }}
650
+ >
651
+ Source not available
652
+ </div>
653
+ )
654
+ }
655
+
656
+ const handleCopy = (): void => {
657
+ void navigator.clipboard.writeText(source).then(() => {
658
+ setCopied(true)
659
+ setTimeout(() => { setCopied(false) }, 2000)
660
+ })
661
+ }
662
+
663
+ return (
664
+ <div style={{ position: 'relative', height: '100%' }}>
665
+ <button
666
+ type="button"
667
+ onClick={handleCopy}
668
+ style={{
669
+ position: 'absolute',
670
+ top: 10,
671
+ right: 12,
672
+ zIndex: 1,
673
+ padding: '3px 9px',
674
+ fontSize: 11,
675
+ background: 'rgba(255,255,255,0.1)',
676
+ color: '#e2e8f0',
677
+ border: '1px solid rgba(255,255,255,0.18)',
678
+ borderRadius: 4,
679
+ cursor: 'pointer',
680
+ }}
681
+ >
682
+ {copied ? '✓ Copied' : 'Copy'}
683
+ </button>
684
+
685
+ <Highlight code={source.trim()} language="tsx" theme={theme}>
686
+ {({ style, tokens, getLineProps, getTokenProps }) => (
687
+ <pre
688
+ style={{
689
+ ...style,
690
+ margin: 0,
691
+ padding: '12px 0',
692
+ fontSize: 12.5,
693
+ lineHeight: 1.7,
694
+ fontFamily:
695
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
696
+ height: '100%',
697
+ boxSizing: 'border-box',
698
+ overflow: 'auto',
699
+ }}
700
+ >
701
+ {tokens.map((line, i) => (
702
+ <div
703
+ key={i}
704
+ {...getLineProps({ line })}
705
+ style={{
706
+ ...getLineProps({ line }).style,
707
+ display: 'flex',
708
+ paddingRight: 24,
709
+ }}
710
+ >
711
+ <span
712
+ style={{
713
+ userSelect: 'none',
714
+ minWidth: 40,
715
+ paddingLeft: 14,
716
+ paddingRight: 14,
717
+ textAlign: 'right',
718
+ color: 'rgba(148,163,184,0.45)',
719
+ flexShrink: 0,
720
+ }}
721
+ >
722
+ {i + 1}
723
+ </span>
724
+ <span>
725
+ {line.map((token, key) => (
726
+ <span key={key} {...getTokenProps({ token })} />
727
+ ))}
728
+ </span>
729
+ </div>
730
+ ))}
731
+ </pre>
732
+ )}
733
+ </Highlight>
734
+ </div>
735
+ )
736
+ }
737
+
738
+ // Re-export type for ui consumers that may want to type their own wrappers.
739
+ export type { UseEntryState }