@northlight/ui 2.39.4 → 2.41.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.
@@ -0,0 +1,308 @@
1
+ import { screen, within } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { type ComponentType, createElement, useEffect, useRef, useState } from 'react'
4
+ import { MediatoolThemeProvider, theme } from '../../../lib'
5
+ import type { ComponentScenarios, PlayContext, Scenario } from '../types'
6
+ import { ErrorBoundary } from './error-boundary'
7
+ import './sandbox-viewer.css'
8
+
9
+ function RenderErrorFallback ({ error }: { error: Error }) {
10
+ return (
11
+ <div className="sandbox-error">
12
+ <div className="sandbox-error-title">Render Error</div>
13
+ <pre className="sandbox-error-message">{ error.message }</pre>
14
+ <pre className="sandbox-error-stack">{ error.stack }</pre>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ type PlayError = {
20
+ scenarioName: string
21
+ error: Error
22
+ }
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
+ }
77
+
78
+ type Selection = {
79
+ componentSlug: string
80
+ scenarioSlug: string
81
+ }
82
+
83
+ type SandboxViewerProps = {
84
+ scenarios: ComponentScenarios[]
85
+ }
86
+
87
+ function toSlug (name: string): string {
88
+ return name.toLowerCase().replace(/\s+/g, '-')
89
+ }
90
+
91
+ function parsePath (): Selection | null {
92
+ const path = window.location.pathname
93
+ const parts = path.split('/').filter(Boolean)
94
+ if (parts.length === 0) return null
95
+ return {
96
+ componentSlug: parts[0],
97
+ scenarioSlug: parts[1] ?? '',
98
+ }
99
+ }
100
+
101
+ function toPath (componentSlug: string, scenarioSlug: string): string {
102
+ return `/${componentSlug}/${scenarioSlug}`
103
+ }
104
+
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)
112
+
113
+ const filteredScenarios = search
114
+ ? scenarios.filter((c) => {
115
+ const searchTerms = search.toLowerCase().split(/\s+/).filter(Boolean)
116
+ const nameWords = c.name.toLowerCase().split(/\s+/)
117
+ return searchTerms.every((term) =>
118
+ nameWords.some((word) => word.startsWith(term))
119
+ )
120
+ })
121
+ : scenarios
122
+
123
+ useEffect(() => {
124
+ const handlePopState = () => setSelection(parsePath())
125
+ window.addEventListener('popstate', handlePopState)
126
+ 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
+ }
134
+
135
+ const handleSelectComponent = (component: ComponentScenarios<unknown>) => {
136
+ const componentSlug = toSlug(component.name)
137
+ const firstScenario = component.scenarios[0]
138
+ const scenarioSlug = firstScenario ? toSlug(firstScenario.name) : ''
139
+ navigate(componentSlug, scenarioSlug)
140
+ }
141
+
142
+ const handleSelectScenario = (scenarioName: string) => {
143
+ if (!selection) return
144
+ navigate(selection.componentSlug, toSlug(scenarioName))
145
+ }
146
+
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
+ const handlePlay = async (
162
+ scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
163
+ getContainer: () => HTMLElement | null
164
+ ) => {
165
+ if (!scenario.play) return
166
+
167
+ // Clear previous error and reset component
168
+ setPlayError(null)
169
+ setPlayKey((k) => k + 1)
170
+
171
+ // Wait for component to remount
172
+ await new Promise((resolve) => {
173
+ setTimeout(resolve, 50)
174
+ })
175
+
176
+ setPlayingScenario(scenario.name)
177
+
178
+ const container = getContainer()
179
+ if (!container) return
180
+
181
+ const user = userEvent.setup()
182
+ const context: PlayContext = {
183
+ screen: within(container),
184
+ documentScreen: screen,
185
+ user,
186
+ container,
187
+ }
188
+
189
+ try {
190
+ await scenario.play(context)
191
+ } catch (err) {
192
+ setPlayError({
193
+ scenarioName: scenario.name,
194
+ error: err instanceof Error ? err : new Error(String(err)),
195
+ })
196
+ }
197
+
198
+ setPlayingScenario(null)
199
+ }
200
+
201
+ return (
202
+ <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>
296
+ </div>
297
+ )
298
+ }
299
+
300
+ function SandboxViewer ({ scenarios }: SandboxViewerProps) {
301
+ return (
302
+ <MediatoolThemeProvider theme={ theme }>
303
+ <SandboxViewerContent scenarios={ scenarios } />
304
+ </MediatoolThemeProvider>
305
+ )
306
+ }
307
+
308
+ export { SandboxViewer, toSlug }