@northlight/ui 2.41.1 → 2.42.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.
@@ -21,59 +21,9 @@ type PlayError = {
21
21
  error: Error
22
22
  }
23
23
 
24
- type InlineScenarioProps = {
25
- scenario: Scenario<unknown>
26
- component: ComponentType<unknown>
27
- playingScenario: string | null
28
- playError: PlayError | null
29
- playKey: number
30
- onPlay: (
31
- scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
32
- getContainer: () => HTMLElement | null,
33
- ) => void
34
- }
35
-
36
- function InlineScenario (
37
- { scenario, component, playingScenario, playError, playKey, onPlay }: InlineScenarioProps
38
- ) {
39
- const ref = useRef<HTMLDivElement>(null)
40
- const isPlaying = playingScenario === scenario.name
41
- const hasError = playError?.scenarioName === scenario.name
42
-
43
- return (
44
- <div className="sandbox-inline-scenario">
45
- <div className="sandbox-inline-label">{ scenario.name }</div>
46
- <div className="sandbox-inline-preview" ref={ ref }>
47
- <ErrorBoundary fallback={ RenderErrorFallback }>
48
- <div key={ `${scenario.name}-${playKey}` }>
49
- { createElement(
50
- scenario.component ?? component,
51
- scenario.props as Record<string, unknown>
52
- ) }
53
- </div>
54
- </ErrorBoundary>
55
- { hasError && (
56
- <div className="sandbox-play-error">
57
- <div className="sandbox-play-error-title">Play Error</div>
58
- <pre className="sandbox-play-error-message">{ playError.error.message }</pre>
59
- </div>
60
- ) }
61
- </div>
62
- <div className="sandbox-play-slot">
63
- { scenario.play && (
64
- <button
65
- type="button"
66
- className={ `sandbox-play-inline ${hasError ? 'error' : ''}` }
67
- onClick={ () => onPlay(scenario, () => ref.current) }
68
- disabled={ isPlaying }
69
- >
70
- { isPlaying ? '...' : hasError ? '!' : '▶' }
71
- </button>
72
- ) }
73
- </div>
74
- </div>
75
- )
76
- }
24
+ type ViewState =
25
+ | { type: 'grid' }
26
+ | { type: 'detail', componentSlug: string }
77
27
 
78
28
  type Selection = {
79
29
  componentSlug: string
@@ -82,6 +32,12 @@ type Selection = {
82
32
 
83
33
  type SandboxViewerProps = {
84
34
  scenarios: ComponentScenarios[]
35
+ /** Custom title for the landing page */
36
+ title?: string
37
+ /** Custom description for the landing page */
38
+ description?: string
39
+ /** Whether to show the hero section (default: true) */
40
+ showHero?: boolean
85
41
  }
86
42
 
87
43
  function toSlug (name: string): string {
@@ -102,14 +58,280 @@ function toPath (componentSlug: string, scenarioSlug: string): string {
102
58
  return `/${componentSlug}/${scenarioSlug}`
103
59
  }
104
60
 
105
- function SandboxViewerContent ({ scenarios }: SandboxViewerProps) {
106
- const [ selection, setSelection ] = useState<Selection | null>(parsePath)
107
- const [ playingScenario, setPlayingScenario ] = useState<string | null>(null)
108
- const [ playError, setPlayError ] = useState<PlayError | null>(null)
109
- const [ playKey, setPlayKey ] = useState(0)
110
- const [ search, setSearch ] = useState('')
111
- const containerRef = useRef<HTMLDivElement>(null)
61
+ function CodeIcon () {
62
+ return (
63
+ <svg
64
+ width="16"
65
+ height="16"
66
+ viewBox="0 0 24 24"
67
+ fill="none"
68
+ stroke="currentColor"
69
+ strokeWidth="2"
70
+ strokeLinecap="round"
71
+ strokeLinejoin="round"
72
+ aria-hidden="true"
73
+ >
74
+ <path d="m18 16 4-4-4-4" />
75
+ <path d="m6 8-4 4 4 4" />
76
+ <path d="m14.5 4-5 16" />
77
+ </svg>
78
+ )
79
+ }
80
+
81
+ function EyeIcon () {
82
+ return (
83
+ <svg
84
+ width="14"
85
+ height="14"
86
+ viewBox="0 0 24 24"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ strokeWidth="2"
90
+ strokeLinecap="round"
91
+ strokeLinejoin="round"
92
+ aria-hidden="true"
93
+ >
94
+ <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
95
+ <circle cx="12" cy="12" r="3" />
96
+ </svg>
97
+ )
98
+ }
99
+
100
+ function Code2Icon () {
101
+ return (
102
+ <svg
103
+ width="14"
104
+ height="14"
105
+ viewBox="0 0 24 24"
106
+ fill="none"
107
+ stroke="currentColor"
108
+ strokeWidth="2"
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ aria-hidden="true"
112
+ >
113
+ <path d="m18 16 4-4-4-4" />
114
+ <path d="m6 8-4 4 4 4" />
115
+ </svg>
116
+ )
117
+ }
118
+
119
+ function CopyIcon () {
120
+ return (
121
+ <svg
122
+ width="14"
123
+ height="14"
124
+ viewBox="0 0 24 24"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ strokeWidth="2"
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ aria-hidden="true"
131
+ >
132
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
133
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
134
+ </svg>
135
+ )
136
+ }
137
+
138
+ function CheckIcon () {
139
+ return (
140
+ <svg
141
+ width="14"
142
+ height="14"
143
+ viewBox="0 0 24 24"
144
+ fill="none"
145
+ stroke="currentColor"
146
+ strokeWidth="2"
147
+ strokeLinecap="round"
148
+ strokeLinejoin="round"
149
+ aria-hidden="true"
150
+ >
151
+ <path d="M20 6 9 17l-5-5" />
152
+ </svg>
153
+ )
154
+ }
155
+
156
+ function PlayIcon () {
157
+ return (
158
+ <svg
159
+ width="14"
160
+ height="14"
161
+ viewBox="0 0 24 24"
162
+ fill="currentColor"
163
+ aria-hidden="true"
164
+ >
165
+ <polygon points="5 3 19 12 5 21 5 3" />
166
+ </svg>
167
+ )
168
+ }
169
+
170
+ function ArrowLeftIcon () {
171
+ return (
172
+ <svg
173
+ width="16"
174
+ height="16"
175
+ viewBox="0 0 24 24"
176
+ fill="none"
177
+ stroke="currentColor"
178
+ strokeWidth="2"
179
+ strokeLinecap="round"
180
+ strokeLinejoin="round"
181
+ aria-hidden="true"
182
+ >
183
+ <path d="m12 19-7-7 7-7" />
184
+ <path d="M19 12H5" />
185
+ </svg>
186
+ )
187
+ }
188
+
189
+ function ExternalLinkIcon () {
190
+ return (
191
+ <svg
192
+ width="16"
193
+ height="16"
194
+ viewBox="0 0 24 24"
195
+ fill="none"
196
+ stroke="currentColor"
197
+ strokeWidth="2"
198
+ strokeLinecap="round"
199
+ strokeLinejoin="round"
200
+ aria-hidden="true"
201
+ >
202
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
203
+ <polyline points="15 3 21 3 21 9" />
204
+ <line x1="10" x2="21" y1="14" y2="3" />
205
+ </svg>
206
+ )
207
+ }
208
+
209
+ type SidebarProps = {
210
+ scenarios: ComponentScenarios[]
211
+ search: string
212
+ onSearchChange: (value: string) => void
213
+ selectedComponent: ComponentScenarios | null
214
+ onSelectComponent: (component: ComponentScenarios) => void
215
+ }
216
+
217
+ function Sidebar ({
218
+ scenarios,
219
+ search,
220
+ onSearchChange,
221
+ selectedComponent,
222
+ onSelectComponent,
223
+ }: SidebarProps): JSX.Element {
224
+ const filteredScenarios = search
225
+ ? scenarios.filter((c) => {
226
+ const searchTerms = search.toLowerCase().split(/\s+/).filter(Boolean)
227
+ const nameWords = c.name.toLowerCase().split(/\s+/)
228
+ return searchTerms.every((term) =>
229
+ nameWords.some((word) => word.startsWith(term))
230
+ )
231
+ })
232
+ : scenarios
233
+
234
+ return (
235
+ <nav className="sandbox-sidebar">
236
+ <div className="sandbox-logo">
237
+ <div className="sandbox-logo-icon">
238
+ <CodeIcon />
239
+ </div>
240
+ <span className="sandbox-logo-text">Sandbox</span>
241
+ </div>
242
+
243
+ <div className="sandbox-sidebar-search">
244
+ <input
245
+ type="text"
246
+ className="sandbox-sidebar-search-input"
247
+ placeholder="Search components..."
248
+ value={ search }
249
+ onChange={ (e) => onSearchChange(e.target.value) }
250
+ />
251
+ </div>
252
+
253
+ <div className="sandbox-nav">
254
+ <div className="sandbox-nav-section">
255
+ <div className="sandbox-nav-title">Components</div>
256
+ { filteredScenarios.map((component) => (
257
+ <button
258
+ type="button"
259
+ key={ component.name }
260
+ className={ `sandbox-nav-item ${selectedComponent?.name === component.name ? 'selected' : ''}` }
261
+ onClick={ () => onSelectComponent(component) }
262
+ >
263
+ <span className="sandbox-nav-item-icon">{ '{ }' }</span>
264
+ { component.name }
265
+ </button>
266
+ )) }
267
+ </div>
268
+ </div>
269
+ </nav>
270
+ )
271
+ }
112
272
 
273
+ type HeroProps = {
274
+ title: string
275
+ description: string
276
+ }
277
+
278
+ function Hero ({ title, description }: HeroProps) {
279
+ return (
280
+ <div className="sandbox-hero">
281
+ <h1 className="sandbox-hero-title">{ title }</h1>
282
+ <p className="sandbox-hero-description">{ description }</p>
283
+ </div>
284
+ )
285
+ }
286
+
287
+ type ComponentCardProps = {
288
+ component: ComponentScenarios
289
+ onSelect: () => void
290
+ }
291
+
292
+ function ComponentCard ({ component, onSelect }: ComponentCardProps) {
293
+ const firstScenario = component.scenarios[0]
294
+ const scenarioCount = component.scenarios.length
295
+
296
+ return (
297
+ <div className="sandbox-card" onClick={ onSelect } onKeyDown={ onSelect } role="button" tabIndex={ 0 }>
298
+ <div className="sandbox-card-preview">
299
+ { firstScenario && (
300
+ <ErrorBoundary fallback={ RenderErrorFallback }>
301
+ { createElement(
302
+ firstScenario.component ?? component.component,
303
+ firstScenario.props as Record<string, unknown>
304
+ ) }
305
+ </ErrorBoundary>
306
+ ) }
307
+ <div className="sandbox-card-actions">
308
+ <button type="button" className="sandbox-card-btn sandbox-card-btn-primary" onClick={ onSelect }>
309
+ <ExternalLinkIcon />
310
+ Open
311
+ </button>
312
+ </div>
313
+ </div>
314
+ <div className="sandbox-card-info">
315
+ <h3 className="sandbox-card-title">{ component.name }</h3>
316
+ <p className="sandbox-card-description">
317
+ { scenarioCount } { scenarioCount === 1 ? 'scenario' : 'scenarios' }
318
+ </p>
319
+ </div>
320
+ </div>
321
+ )
322
+ }
323
+
324
+ type GridViewProps = {
325
+ scenarios: ComponentScenarios[]
326
+ search: string
327
+ onSelectComponent: (component: ComponentScenarios) => void
328
+ title: string
329
+ description: string
330
+ showHero: boolean
331
+ }
332
+
333
+ function GridView
334
+ ({ scenarios, search, onSelectComponent, title, description, showHero }: GridViewProps) {
113
335
  const filteredScenarios = search
114
336
  ? scenarios.filter((c) => {
115
337
  const searchTerms = search.toLowerCase().split(/\s+/).filter(Boolean)
@@ -120,55 +342,455 @@ function SandboxViewerContent ({ scenarios }: SandboxViewerProps) {
120
342
  })
121
343
  : scenarios
122
344
 
345
+ return (
346
+ <>
347
+ { showHero && (
348
+ <Hero
349
+ title={ title }
350
+ description={ description }
351
+ />
352
+ ) }
353
+ <div className="sandbox-grid">
354
+ { filteredScenarios.map((component) => (
355
+ <ComponentCard
356
+ key={ component.name }
357
+ component={ component }
358
+ onSelect={ () => onSelectComponent(component) }
359
+ />
360
+ )) }
361
+ </div>
362
+ </>
363
+ )
364
+ }
365
+
366
+ type ScenarioCardProps = {
367
+ scenario: Scenario<unknown>
368
+ component: ComponentType<unknown>
369
+ playingScenario: string | null
370
+ playError: PlayError | null
371
+ playKeys: Record<string, number>
372
+ onPlay: (
373
+ scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
374
+ getContainer: () => HTMLElement | null
375
+ ) => void
376
+ }
377
+
378
+ function ScenarioCard ({
379
+ scenario,
380
+ component,
381
+ playingScenario,
382
+ playError,
383
+ playKeys,
384
+ onPlay,
385
+ }: ScenarioCardProps) {
386
+ const [ viewMode, setViewMode ] = useState<'preview' | 'code'>('preview')
387
+ const [ copied, setCopied ] = useState(false)
388
+ const containerRef = useRef<HTMLDivElement>(null)
389
+
390
+ const isPlaying = playingScenario === scenario.name
391
+ const hasError = playError?.scenarioName === scenario.name
392
+
393
+ const getCodeDisplay = () => {
394
+ if (scenario.code) {
395
+ return scenario.code
396
+ }
397
+ const componentName = component.displayName || component.name || 'Component'
398
+ return `<${componentName} {...props} />`
399
+ }
400
+
401
+ const handleCopy = () => {
402
+ navigator.clipboard.writeText(getCodeDisplay())
403
+ setCopied(true)
404
+ setTimeout(() => setCopied(false), 2000)
405
+ }
406
+
407
+ return (
408
+ <div className="sandbox-scenario-card">
409
+ <div className="sandbox-scenario-header">
410
+ <div className="sandbox-scenario-info">
411
+ <h3>{ scenario.name }</h3>
412
+ </div>
413
+ <div className="sandbox-scenario-actions">
414
+ { scenario.code && (
415
+ <button
416
+ type="button"
417
+ className={ `sandbox-scenario-btn sandbox-scenario-btn-toggle ${viewMode === 'code' ? 'active' : ''}` }
418
+ onClick={ () => setViewMode(viewMode === 'code' ? 'preview' : 'code') }
419
+ >
420
+ { viewMode === 'code' ? (
421
+ <>
422
+ <EyeIcon />
423
+ Preview
424
+ </>
425
+ ) : (
426
+ <>
427
+ <Code2Icon />
428
+ Code
429
+ </>
430
+ ) }
431
+ </button>
432
+ ) }
433
+ { scenario.code && (
434
+ <button
435
+ type="button"
436
+ className="sandbox-scenario-btn sandbox-scenario-btn-copy"
437
+ onClick={ handleCopy }
438
+ >
439
+ { copied ? (
440
+ <>
441
+ <CheckIcon />
442
+ Copied
443
+ </>
444
+ ) : (
445
+ <>
446
+ <CopyIcon />
447
+ Copy
448
+ </>
449
+ ) }
450
+ </button>
451
+ ) }
452
+ { scenario.play && (
453
+ <button
454
+ type="button"
455
+ className={ `sandbox-scenario-btn sandbox-scenario-btn-play ${hasError ? 'error' : ''}` }
456
+ onClick={ () => onPlay(scenario, () => containerRef.current) }
457
+ disabled={ isPlaying }
458
+ >
459
+ <PlayIcon />
460
+ { isPlaying ? 'Playing...' : hasError ? 'Retry' : 'Play' }
461
+ </button>
462
+ ) }
463
+ </div>
464
+ </div>
465
+ <div className="sandbox-scenario-content">
466
+ { viewMode === 'code' && scenario.code ? (
467
+ <div className="sandbox-scenario-code">
468
+ <pre>{ scenario.code }</pre>
469
+ </div>
470
+ ) : (
471
+ <div className="sandbox-scenario-preview" ref={ containerRef }>
472
+ <ErrorBoundary fallback={ RenderErrorFallback }>
473
+ <div key={ `${scenario.name}-${playKeys[scenario.name] ?? 0}` }>
474
+ { createElement(
475
+ scenario.component ?? component,
476
+ scenario.props as Record<string, unknown>
477
+ ) }
478
+ </div>
479
+ </ErrorBoundary>
480
+ </div>
481
+ ) }
482
+ { hasError && playError && (
483
+ <div className="sandbox-play-error">
484
+ <div className="sandbox-play-error-title">Play Error</div>
485
+ <pre className="sandbox-play-error-message">{ playError.error.message }</pre>
486
+ </div>
487
+ ) }
488
+ </div>
489
+ </div>
490
+ )
491
+ }
492
+
493
+ type DetailViewProps = {
494
+ component: ComponentScenarios
495
+ onBack: () => void
496
+ playingScenario: string | null
497
+ playError: PlayError | null
498
+ playKeys: Record<string, number>
499
+ onPlay: (
500
+ scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
501
+ getContainer: () => HTMLElement | null
502
+ ) => void
503
+ }
504
+
505
+ function DetailView ({
506
+ component,
507
+ onBack,
508
+ playingScenario,
509
+ playError,
510
+ playKeys,
511
+ onPlay,
512
+ }: DetailViewProps) {
513
+ const scenarioCount = component.scenarios.length
514
+
515
+ return (
516
+ <div className="sandbox-detail">
517
+ <button type="button" className="sandbox-detail-back" onClick={ onBack }>
518
+ <ArrowLeftIcon />
519
+ Back to components
520
+ </button>
521
+
522
+ <div className="sandbox-detail-header">
523
+ <div>
524
+ <div className="sandbox-detail-title-row">
525
+ <h1 className="sandbox-detail-title">{ component.name }</h1>
526
+ <span className="sandbox-detail-badge">
527
+ { scenarioCount } { scenarioCount === 1 ? 'scenario' : 'scenarios' }
528
+ </span>
529
+ </div>
530
+ <p className="sandbox-detail-description">
531
+ Component scenarios with interactive preview and code view.
532
+ </p>
533
+ </div>
534
+ </div>
535
+
536
+ <div className="sandbox-scenarios">
537
+ { component.scenarios.map((scenario) => (
538
+ <ScenarioCard
539
+ key={ scenario.name }
540
+ scenario={ scenario }
541
+ component={ component.component }
542
+ playingScenario={ playingScenario }
543
+ playError={ playError }
544
+ playKeys={ playKeys }
545
+ onPlay={ onPlay }
546
+ />
547
+ )) }
548
+ </div>
549
+ </div>
550
+ )
551
+ }
552
+
553
+ type TabbedDetailViewProps = {
554
+ component: ComponentScenarios
555
+ onBack: () => void
556
+ playingScenario: string | null
557
+ playError: PlayError | null
558
+ playKeys: Record<string, number>
559
+ onPlay: (
560
+ scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
561
+ getContainer: () => HTMLElement | null
562
+ ) => void
563
+ }
564
+
565
+ function TabbedDetailView ({
566
+ component,
567
+ onBack,
568
+ playingScenario,
569
+ playError,
570
+ playKeys,
571
+ onPlay,
572
+ }: TabbedDetailViewProps) {
573
+ const [ activeTab, setActiveTab ] = useState(0)
574
+ const [ viewMode, setViewMode ] = useState<'preview' | 'code'>('preview')
575
+ const [ copied, setCopied ] = useState(false)
576
+ const containerRef = useRef<HTMLDivElement>(null)
577
+
578
+ const activeScenario = component.scenarios[activeTab]
579
+ const scenarioCount = component.scenarios.length
580
+ const isPlaying = playingScenario === activeScenario?.name
581
+ const hasError = playError?.scenarioName === activeScenario?.name
582
+
583
+ const handleCopy = () => {
584
+ if (activeScenario?.code) {
585
+ navigator.clipboard.writeText(activeScenario.code)
586
+ setCopied(true)
587
+ setTimeout(() => setCopied(false), 2000)
588
+ }
589
+ }
590
+
591
+ return (
592
+ <div className="sandbox-detail-tabbed">
593
+ <button type="button" className="sandbox-detail-back" onClick={ onBack }>
594
+ <ArrowLeftIcon />
595
+ Back to components
596
+ </button>
597
+
598
+ <div className="sandbox-detail-header">
599
+ <div>
600
+ <div className="sandbox-detail-title-row">
601
+ <h1 className="sandbox-detail-title">{ component.name }</h1>
602
+ <span className="sandbox-badge-advanced">Advanced</span>
603
+ </div>
604
+ <p className="sandbox-detail-description">
605
+ Component scenarios with interactive preview and code view.
606
+ </p>
607
+ </div>
608
+ </div>
609
+
610
+ <div className="sandbox-tabbed-card">
611
+ { /* Tabs Bar */ }
612
+ <div className="sandbox-tabs-bar">
613
+ <div className="sandbox-tabs-list">
614
+ { component.scenarios.map((scenario, index) => (
615
+ <button
616
+ type="button"
617
+ key={ scenario.name }
618
+ className={ `sandbox-tab-item ${activeTab === index ? 'active' : ''}` }
619
+ onClick={ () => setActiveTab(index) }
620
+ >
621
+ { scenario.name }
622
+ </button>
623
+ )) }
624
+ </div>
625
+
626
+ <div className="sandbox-tabs-actions">
627
+ { activeScenario?.code && (
628
+ <button
629
+ type="button"
630
+ className={ `sandbox-scenario-btn sandbox-scenario-btn-toggle ${viewMode === 'code' ? 'active' : ''}` }
631
+ onClick={ () => setViewMode(viewMode === 'code' ? 'preview' : 'code') }
632
+ >
633
+ { viewMode === 'code' ? (
634
+ <>
635
+ <EyeIcon />
636
+ Preview
637
+ </>
638
+ ) : (
639
+ <>
640
+ <Code2Icon />
641
+ Code
642
+ </>
643
+ ) }
644
+ </button>
645
+ ) }
646
+ { activeScenario?.code && (
647
+ <button
648
+ type="button"
649
+ className="sandbox-scenario-btn sandbox-scenario-btn-copy"
650
+ onClick={ handleCopy }
651
+ >
652
+ { copied ? (
653
+ <>
654
+ <CheckIcon />
655
+ Copied
656
+ </>
657
+ ) : (
658
+ <>
659
+ <CopyIcon />
660
+ Copy
661
+ </>
662
+ ) }
663
+ </button>
664
+ ) }
665
+ { activeScenario?.play && (
666
+ <button
667
+ type="button"
668
+ className={ `sandbox-scenario-btn sandbox-scenario-btn-play ${hasError ? 'error' : ''}` }
669
+ onClick={ () => onPlay(activeScenario, () => containerRef.current) }
670
+ disabled={ isPlaying }
671
+ >
672
+ <PlayIcon />
673
+ { isPlaying ? 'Playing...' : hasError ? 'Retry' : 'Play' }
674
+ </button>
675
+ ) }
676
+ </div>
677
+ </div>
678
+
679
+ { /* Content */ }
680
+ <div className="sandbox-tab-content">
681
+ { viewMode === 'code' && activeScenario?.code ? (
682
+ <div className="sandbox-tab-code">
683
+ <pre>{ activeScenario.code }</pre>
684
+ </div>
685
+ ) : (
686
+ <div className="sandbox-tab-preview" ref={ containerRef }>
687
+ { activeScenario && (
688
+ <ErrorBoundary fallback={ RenderErrorFallback }>
689
+ <div key={ `${activeScenario.name}-${playKeys[activeScenario.name] ?? 0}` }>
690
+ { createElement(
691
+ activeScenario.component ?? component.component,
692
+ activeScenario.props as Record<string, unknown>
693
+ ) }
694
+ </div>
695
+ </ErrorBoundary>
696
+ ) }
697
+ </div>
698
+ ) }
699
+ { hasError && playError && (
700
+ <div className="sandbox-play-error">
701
+ <div className="sandbox-play-error-title">Play Error</div>
702
+ <pre className="sandbox-play-error-message">{ playError.error.message }</pre>
703
+ </div>
704
+ ) }
705
+ </div>
706
+ </div>
707
+
708
+ { /* Navigation Dots */ }
709
+ { scenarioCount > 1 && (
710
+ <div className="sandbox-nav-dots">
711
+ { component.scenarios.map((scenario, index) => (
712
+ <button
713
+ type="button"
714
+ key={ scenario.name }
715
+ className={ `sandbox-nav-dot ${activeTab === index ? 'active' : 'inactive'}` }
716
+ onClick={ () => setActiveTab(index) }
717
+ aria-label={ `Go to scenario ${index + 1}` }
718
+ />
719
+ )) }
720
+ </div>
721
+ ) }
722
+ </div>
723
+ )
724
+ }
725
+
726
+ function SandboxViewerContent ({
727
+ scenarios,
728
+ title = 'Northlight Component Library',
729
+ description = 'Northligt component library sandbox for testing and development.',
730
+ showHero = true,
731
+ }: SandboxViewerProps) {
732
+ const [ view, setView ] = useState<ViewState>({ type: 'grid' })
733
+ const [ selectedComponent, setSelectedComponent ] = useState<ComponentScenarios | null>(null)
734
+ const [ search, setSearch ] = useState('')
735
+ const [ playingScenario, setPlayingScenario ] = useState<string | null>(null)
736
+ const [ playError, setPlayError ] = useState<PlayError | null>(null)
737
+ const [ playKeys, setPlayKeys ] = useState<Record<string, number>>({})
738
+
739
+ useEffect(() => {
740
+ const selection = parsePath()
741
+ if (selection) {
742
+ const component = scenarios.find((c) => toSlug(c.name) === selection.componentSlug)
743
+ if (component) {
744
+ setSelectedComponent(component)
745
+ setView({ type: 'detail', componentSlug: selection.componentSlug })
746
+ }
747
+ }
748
+ }, [ scenarios ])
749
+
123
750
  useEffect(() => {
124
- const handlePopState = () => setSelection(parsePath())
751
+ const handlePopState = () => {
752
+ const selection = parsePath()
753
+ if (selection) {
754
+ const component = scenarios.find((c) => toSlug(c.name) === selection.componentSlug)
755
+ if (component) {
756
+ setSelectedComponent(component)
757
+ setView({ type: 'detail', componentSlug: selection.componentSlug })
758
+ return
759
+ }
760
+ }
761
+ setSelectedComponent(null)
762
+ setView({ type: 'grid' })
763
+ }
125
764
  window.addEventListener('popstate', handlePopState)
126
765
  return () => window.removeEventListener('popstate', handlePopState)
127
- }, [])
128
-
129
- const navigate = (componentSlug: string, scenarioSlug: string) => {
130
- const path = toPath(componentSlug, scenarioSlug)
131
- window.history.pushState(null, '', path)
132
- setSelection({ componentSlug, scenarioSlug })
133
- }
766
+ }, [ scenarios ])
134
767
 
135
- const handleSelectComponent = (component: ComponentScenarios<unknown>) => {
768
+ const handleSelectComponent = (component: ComponentScenarios) => {
136
769
  const componentSlug = toSlug(component.name)
137
770
  const firstScenario = component.scenarios[0]
138
771
  const scenarioSlug = firstScenario ? toSlug(firstScenario.name) : ''
139
- navigate(componentSlug, scenarioSlug)
772
+ window.history.pushState(null, '', toPath(componentSlug, scenarioSlug))
773
+ setSelectedComponent(component)
774
+ setView({ type: 'detail', componentSlug })
775
+ setPlayError(null)
140
776
  }
141
777
 
142
- const handleSelectScenario = (scenarioName: string) => {
143
- if (!selection) return
144
- navigate(selection.componentSlug, toSlug(scenarioName))
778
+ const handleBack = () => {
779
+ window.history.pushState(null, '', '/')
780
+ setSelectedComponent(null)
781
+ setView({ type: 'grid' })
782
+ setPlayError(null)
145
783
  }
146
784
 
147
- const selectedComponent = selection
148
- ? scenarios.find((c) => toSlug(c.name) === selection.componentSlug)
149
- : null
150
-
151
- const selectedScenario =
152
- selectedComponent && selection?.scenarioSlug
153
- ? selectedComponent.scenarios.find((s) => toSlug(s.name) === selection.scenarioSlug)
154
- : selectedComponent?.scenarios[0]
155
-
156
- const selectionKey =
157
- selectedComponent && selectedScenario
158
- ? `${selectedComponent.name}/${selectedScenario.name}/${playKey}`
159
- : ''
160
-
161
785
  const handlePlay = async (
162
786
  scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
163
787
  getContainer: () => HTMLElement | null
164
788
  ) => {
165
789
  if (!scenario.play) return
166
790
 
167
- // Clear previous error and reset component
168
791
  setPlayError(null)
169
- setPlayKey((k) => k + 1)
792
+ setPlayKeys((keys) => ({ ...keys, [scenario.name]: (keys[scenario.name] ?? 0) + 1 }))
170
793
 
171
- // Wait for component to remount
172
794
  await new Promise((resolve) => {
173
795
  setTimeout(resolve, 50)
174
796
  })
@@ -198,109 +820,74 @@ function SandboxViewerContent ({ scenarios }: SandboxViewerProps) {
198
820
  setPlayingScenario(null)
199
821
  }
200
822
 
823
+ const getLayoutMode = (): 'stacked' | 'tabbed' => {
824
+ if (!selectedComponent) return 'stacked'
825
+ if (selectedComponent.layout) return selectedComponent.layout
826
+ if (selectedComponent.name.toLowerCase() === 'tabs') return 'tabbed'
827
+ return 'stacked'
828
+ }
829
+
830
+ const layoutMode = getLayoutMode()
831
+
201
832
  return (
202
833
  <div className="sandbox-viewer">
203
- <nav className="sandbox-sidebar">
204
- <div className="sandbox-search">
205
- <input
206
- type="text"
207
- className="sandbox-search-input"
208
- placeholder="Search..."
209
- value={ search }
210
- onChange={ (e) => setSearch(e.target.value) }
211
- />
212
- </div>
213
- <div className="sandbox-title">Components</div>
214
- { filteredScenarios.map((component) => (
215
- <button
216
- type="button"
217
- key={ component.name }
218
- className={
219
- selection?.componentSlug === toSlug(component.name)
220
- ? 'sandbox-component selected'
221
- : 'sandbox-component'
222
- }
223
- onClick={ () => handleSelectComponent(component) }
224
- >
225
- { component.name }
226
- </button>
227
- )) }
228
- </nav>
229
- <main className="sandbox-main">
230
- { selectedComponent &&
231
- (selectedComponent.inline ? (
232
- <div className="sandbox-inline">
233
- { selectedComponent.scenarios.map((scenario) => (
234
- <InlineScenario
235
- key={ scenario.name }
236
- scenario={ scenario }
237
- component={ selectedComponent.component }
238
- playingScenario={ playingScenario }
239
- playError={ playError }
240
- playKey={ playKey }
241
- onPlay={ handlePlay }
242
- />
243
- )) }
244
- </div>
245
- ) : (
246
- <>
247
- <div className="sandbox-tabs">
248
- { selectedComponent.scenarios.map((scenario) => (
249
- <button
250
- type="button"
251
- key={ scenario.name }
252
- className={
253
- selectedScenario?.name === scenario.name
254
- ? 'sandbox-tab selected'
255
- : 'sandbox-tab'
256
- }
257
- onClick={ () => handleSelectScenario(scenario.name) }
258
- >
259
- { scenario.name }
260
- </button>
261
- )) }
262
- { selectedScenario?.play && (
263
- <button
264
- type="button"
265
- className={ `sandbox-play ${playError?.scenarioName === selectedScenario.name ? 'error' : ''}` }
266
- onClick={ () => handlePlay(selectedScenario, () => containerRef.current) }
267
- disabled={ playingScenario === selectedScenario.name }
268
- >
269
- { playingScenario === selectedScenario.name
270
- ? 'Playing...'
271
- : playError?.scenarioName === selectedScenario.name
272
- ? 'Failed'
273
- : 'Play' }
274
- </button>
275
- ) }
276
- </div>
277
- <div className="sandbox-preview" ref={ containerRef } key={ selectionKey }>
278
- { selectedScenario && (
279
- <ErrorBoundary fallback={ RenderErrorFallback }>
280
- { createElement(
281
- selectedScenario.component ?? selectedComponent.component,
282
- selectedScenario.props as Record<string, unknown>
283
- ) }
284
- </ErrorBoundary>
285
- ) }
286
- { playError?.scenarioName === selectedScenario?.name && playError && (
287
- <div className="sandbox-play-error">
288
- <div className="sandbox-play-error-title">Play Error</div>
289
- <pre className="sandbox-play-error-message">{ playError.error.message }</pre>
290
- </div>
291
- ) }
292
- </div>
293
- </>
294
- )) }
295
- </main>
834
+ <Sidebar
835
+ scenarios={ scenarios }
836
+ search={ search }
837
+ onSearchChange={ setSearch }
838
+ selectedComponent={ selectedComponent }
839
+ onSelectComponent={ handleSelectComponent }
840
+ />
841
+
842
+ <div className="sandbox-content">
843
+ <main className="sandbox-main">
844
+ { view.type === 'grid' && (
845
+ <GridView
846
+ scenarios={ scenarios }
847
+ search={ search }
848
+ onSelectComponent={ handleSelectComponent }
849
+ title={ title }
850
+ description={ description }
851
+ showHero={ showHero }
852
+ />
853
+ ) }
854
+
855
+ { view.type === 'detail' && selectedComponent && (
856
+ layoutMode === 'tabbed' ? (
857
+ <TabbedDetailView
858
+ component={ selectedComponent }
859
+ onBack={ handleBack }
860
+ playingScenario={ playingScenario }
861
+ playError={ playError }
862
+ playKeys={ playKeys }
863
+ onPlay={ handlePlay }
864
+ />
865
+ ) : (
866
+ <DetailView
867
+ component={ selectedComponent }
868
+ onBack={ handleBack }
869
+ playingScenario={ playingScenario }
870
+ playError={ playError }
871
+ playKeys={ playKeys }
872
+ onPlay={ handlePlay }
873
+ />
874
+ )
875
+ ) }
876
+ </main>
877
+ </div>
296
878
  </div>
297
879
  )
298
880
  }
299
881
 
300
- function SandboxViewer ({ scenarios }: SandboxViewerProps) {
882
+ function SandboxViewer ({ scenarios, title, description, showHero }: SandboxViewerProps) {
301
883
  return (
302
884
  <MediatoolThemeProvider theme={ theme }>
303
- <SandboxViewerContent scenarios={ scenarios } />
885
+ <SandboxViewerContent
886
+ scenarios={ scenarios }
887
+ title={ title }
888
+ description={ description }
889
+ showHero={ showHero }
890
+ />
304
891
  </MediatoolThemeProvider>
305
892
  )
306
893
  }