@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.
- package/dist/es/northlight.d.ts +29 -5
- package/dist/es/northlight.js +2265 -2197
- package/dist/es/northlight.js.map +1 -1
- package/dist/umd/northlight.cjs +2261 -2196
- package/dist/umd/northlight.cjs.map +1 -1
- package/dist/umd/northlight.min.cjs +3 -3
- package/dist/umd/northlight.min.cjs.map +1 -1
- package/package.json +18 -8
- package/sandbox/bin/sandbox.sh +3 -0
- package/sandbox/bin/sandbox.ts +133 -0
- package/sandbox/lib/index.ts +3 -0
- package/sandbox/lib/run-scenarios.ts +60 -0
- package/sandbox/lib/types.ts +35 -0
- package/sandbox/lib/viewer/error-boundary.tsx +34 -0
- package/sandbox/lib/viewer/sandbox-viewer.css +328 -0
- package/sandbox/lib/viewer/sandbox-viewer.tsx +308 -0
|
@@ -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 }
|