@northlight/ui 2.40.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@northlight/ui",
3
- "version": "2.40.0",
3
+ "version": "2.41.0",
4
4
  "description": "Northlight UI library, based on Chakra-ui",
5
5
  "license": "MIT",
6
6
  "author": "Camphouse",
@@ -11,17 +11,23 @@
11
11
  "types": "./dist/es/northlight.d.ts",
12
12
  "import": "./dist/es/northlight.js",
13
13
  "require": "./dist/umd/northlight.cjs"
14
- }
14
+ },
15
+ "./sandbox": "./sandbox/lib/index.ts"
16
+ },
17
+ "bin": {
18
+ "sandbox": "./sandbox/bin/sandbox.sh"
15
19
  },
16
20
  "main": "./dist/es/northlight.js",
17
21
  "types": "./dist/es/northlight.d.ts",
18
22
  "files": [
19
- "dist"
23
+ "dist",
24
+ "sandbox"
20
25
  ],
21
26
  "scripts": {
22
27
  "clean": "rm -rf dist",
23
- "lint": "eslint --ext ts,tsx lib/ test/",
28
+ "lint": "eslint --ext ts,tsx lib/ test/ sandbox/ northlight-sandbox/",
24
29
  "prepublishOnly": "yarn clean && yarn transpile",
30
+ "sandbox": "./sandbox/bin/sandbox.sh northlight-sandbox/scenarios.tsx",
25
31
  "test": "yarn mtft test",
26
32
  "transpile": "rollup -c",
27
33
  "watch": "yarn transpile -w"
@@ -51,7 +57,7 @@
51
57
  "@react-aria/calendar": "^3.5.0",
52
58
  "@react-aria/checkbox": "^3.11.0",
53
59
  "@react-aria/datepicker": "^3.7.0",
54
- "@react-aria/focus": "^3.14.1",
60
+ "@react-aria/focus": "3.14.1",
55
61
  "@react-aria/i18n": "^3.8.2",
56
62
  "@react-aria/radio": "^3.8.0",
57
63
  "@react-aria/utils": "^3.20.0",
@@ -65,6 +71,7 @@
65
71
  "@types/react-router-dom": "^5.3.2",
66
72
  "@types/react-virtualized-auto-sizer": "^1.0.1",
67
73
  "@types/react-window": "^1.8.5",
74
+ "@vitejs/plugin-react": "^4.3.0",
68
75
  "chakra-react-select": "4.7.2",
69
76
  "framer-motion": "^4",
70
77
  "joi": "^17.11.0",
@@ -78,6 +85,8 @@
78
85
  "react-virtualized-auto-sizer": "^1.0.20",
79
86
  "react-window": "^1.8.9",
80
87
  "sinon": "^15.2.0",
88
+ "tsx": "^4.21.0",
89
+ "vite": "^6.0.0",
81
90
  "yafu": "^2.1.4"
82
91
  },
83
92
  "devDependencies": {
@@ -101,12 +110,13 @@
101
110
  "rollup-plugin-dts": "^5.2.0",
102
111
  "rollup-plugin-esbuild": "^5.0.0",
103
112
  "sinon": "^15.2.0",
104
- "typescript": "^5.2.2"
113
+ "typescript": "^5.2.2",
114
+ "vitest": "^2.0.0"
105
115
  },
106
116
  "peerDependencies": {
107
117
  "react": ">=18.0.0",
108
118
  "react-dom": ">=18.0.0",
109
119
  "react-router-dom": "^5.0.0"
110
120
  },
111
- "gitHead": "be842f0a8e2c4e7f67a5120dc8af4500ecf90686"
121
+ "gitHead": "fd559db249b74856dcf44ba2615913607647637a"
112
122
  }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ DIR="$(dirname "$0")"
3
+ exec pnpx tsx "$DIR/sandbox.ts" "$@"
@@ -0,0 +1,133 @@
1
+ /* eslint-disable no-restricted-syntax */
2
+ import { existsSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import react from '@vitejs/plugin-react'
6
+ import { createServer } from 'vite'
7
+
8
+ const DEFAULT_SCENARIOS_PATHS = [ 'sandbox/scenarios.ts', 'sandbox/scenarios.tsx' ]
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url))
11
+
12
+ function findScenariosFile (cwd: string, scenariosArg?: string): string | undefined {
13
+ if (scenariosArg) {
14
+ const absolutePath = resolve(cwd, scenariosArg)
15
+ return existsSync(absolutePath) ? absolutePath : undefined
16
+ }
17
+ for (const path of DEFAULT_SCENARIOS_PATHS) {
18
+ const absolutePath = resolve(cwd, path)
19
+ if (existsSync(absolutePath)) return absolutePath
20
+ }
21
+ return undefined
22
+ }
23
+
24
+ async function main () {
25
+ const cwd = process.cwd()
26
+ const scenariosArg = process.argv[2]
27
+ const absoluteScenariosPath = findScenariosFile(cwd, scenariosArg)
28
+
29
+ if (!absoluteScenariosPath) {
30
+ console.error('Scenarios file not found.')
31
+ console.error(
32
+ scenariosArg
33
+ ? `Please check the path: ${resolve(cwd, scenariosArg)}`
34
+ : `Create one of: ${DEFAULT_SCENARIOS_PATHS.join(', ')}`
35
+ )
36
+ process.exit(1)
37
+ }
38
+
39
+ const sandboxPackageDir = resolve(__dirname, '..')
40
+
41
+ const virtualEntryId = 'virtual:sandbox-entry.tsx'
42
+ const resolvedVirtualEntryId = `\0${virtualEntryId}`
43
+
44
+ const virtualHtmlId = 'virtual:sandbox-html'
45
+
46
+ const server = await createServer({
47
+ configFile: false,
48
+ root: cwd,
49
+ server: {
50
+ port: 5000,
51
+ strictPort: false,
52
+ },
53
+ plugins: [
54
+ react(),
55
+ {
56
+ name: 'sandbox-virtual-entry',
57
+ resolveId (id) {
58
+ if (id === virtualEntryId) return resolvedVirtualEntryId
59
+ if (id === '/' || id === '/index.html') return virtualHtmlId
60
+ return undefined
61
+ },
62
+ load (id) {
63
+ if (id === resolvedVirtualEntryId) {
64
+ return `
65
+ import { createElement } from 'react'
66
+ import { createRoot } from 'react-dom/client'
67
+ import { SandboxViewer } from '${sandboxPackageDir}/lib/viewer/sandbox-viewer.tsx'
68
+ import { scenarios } from '${absoluteScenariosPath}'
69
+
70
+ const root = document.getElementById('root')
71
+ createRoot(root).render(createElement(SandboxViewer, { scenarios }))
72
+ `
73
+ }
74
+ if (id === virtualHtmlId) {
75
+ return `
76
+ <!DOCTYPE html>
77
+ <html>
78
+ <head>
79
+ <meta charset="UTF-8" />
80
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
81
+ <title>Sandbox</title>
82
+ </head>
83
+ <body>
84
+ <div id="root"></div>
85
+ <script type="module" src="/@id/__x00__${virtualEntryId}"></script>
86
+ </body>
87
+ </html>
88
+ `
89
+ }
90
+ return undefined
91
+ },
92
+ },
93
+ {
94
+ name: 'sandbox-html-middleware',
95
+ configureServer (devServer) {
96
+ devServer.middlewares.use((req, res, next) => {
97
+ const url = req.url ?? ''
98
+ const isAsset =
99
+ url.startsWith('/@') || url.startsWith('/node_modules') || url.includes('.')
100
+
101
+ if (!isAsset) {
102
+ const html = `
103
+ <!DOCTYPE html>
104
+ <html>
105
+ <head>
106
+ <meta charset="UTF-8" />
107
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
108
+ <title>Sandbox</title>
109
+ </head>
110
+ <body>
111
+ <div id="root"></div>
112
+ <script type="module" src="/@id/__x00__${virtualEntryId}"></script>
113
+ </body>
114
+ </html>
115
+ `
116
+ devServer.transformIndexHtml(url, html).then((transformed) => {
117
+ res.setHeader('Content-Type', 'text/html')
118
+ res.end(transformed)
119
+ })
120
+ return
121
+ }
122
+ next()
123
+ })
124
+ },
125
+ },
126
+ ],
127
+ })
128
+
129
+ await server.listen()
130
+ server.printUrls()
131
+ }
132
+
133
+ main()
@@ -0,0 +1,3 @@
1
+ export { runScenarios } from './run-scenarios'
2
+ export type { ComponentScenarios, PlayContext, Scenario } from './types.ts'
3
+ export { SandboxViewer } from './viewer/sandbox-viewer'
@@ -0,0 +1,60 @@
1
+ /* eslint-disable no-restricted-syntax */
2
+ import { cleanup, render, screen, within } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { createElement } from 'react'
5
+ import { MediatoolThemeProvider, theme } from '../../lib'
6
+ import type { ComponentScenarios, PlayContext } from './types.ts'
7
+
8
+ declare const describe: (name: string, fn: () => void) => void
9
+ declare const it: (name: string, fn: () => void | Promise<void>) => void
10
+
11
+ if (typeof window !== 'undefined' && !window.matchMedia) {
12
+ window.matchMedia = (query: string) => ({
13
+ matches: false,
14
+ media: query,
15
+ onchange: null,
16
+ addListener: () => {},
17
+ removeListener: () => {},
18
+ addEventListener: () => {},
19
+ removeEventListener: () => {},
20
+ dispatchEvent: () => false,
21
+ })
22
+ }
23
+
24
+ function runScenarios (allScenarios: ComponentScenarios[]): void {
25
+ for (const componentScenarios of allScenarios) {
26
+ const { name: componentName, component: defaultComponent, scenarios } = componentScenarios
27
+
28
+ describe(componentName, () => {
29
+ for (const scenario of scenarios) {
30
+ const { name: scenarioName, props, component: scenarioComponent, play } = scenario
31
+ const component = scenarioComponent ?? defaultComponent
32
+
33
+ it(scenarioName, async () => {
34
+ const user = userEvent.setup()
35
+ const { container } = render(
36
+ createElement(
37
+ MediatoolThemeProvider,
38
+ { theme },
39
+ createElement(component, props as Record<string, unknown>)
40
+ )
41
+ )
42
+
43
+ if (play) {
44
+ const context: PlayContext = {
45
+ screen: within(container),
46
+ documentScreen: screen,
47
+ user,
48
+ container,
49
+ }
50
+ await play(context)
51
+ }
52
+
53
+ cleanup()
54
+ })
55
+ }
56
+ })
57
+ }
58
+ }
59
+
60
+ export { runScenarios }
@@ -0,0 +1,35 @@
1
+ import type { screen, within } from '@testing-library/react'
2
+ import type userEvent from '@testing-library/user-event'
3
+ import type { ComponentType } from 'react'
4
+
5
+ type PlayContext = {
6
+ /** Queries scoped to the scenario container - use for non-portaled content */
7
+ screen: ReturnType<typeof within>
8
+ /** Queries scoped to document.body - use for portaled content (dropdowns, modals) */
9
+ documentScreen: typeof screen
10
+ user: ReturnType<typeof userEvent.setup>
11
+ container: HTMLElement
12
+ }
13
+
14
+ type Scenario<P> =
15
+ | {
16
+ name: string
17
+ props: P
18
+ component?: undefined
19
+ play?: (context: PlayContext) => Promise<void>
20
+ }
21
+ | {
22
+ name: string
23
+ props: Record<string, unknown>
24
+ component: ComponentType<any>
25
+ play?: (context: PlayContext) => Promise<void>
26
+ }
27
+
28
+ type ComponentScenarios<P = any> = {
29
+ name: string
30
+ component: ComponentType<P>
31
+ scenarios: Scenario<P>[]
32
+ inline?: boolean
33
+ }
34
+
35
+ export type { PlayContext, Scenario, ComponentScenarios }
@@ -0,0 +1,34 @@
1
+ import { Component, type ComponentType, type ReactNode, createElement } from 'react'
2
+
3
+ type ErrorBoundaryProps = {
4
+ children: ReactNode
5
+ fallback: ComponentType<{ error: Error }>
6
+ }
7
+
8
+ type ErrorBoundaryState = {
9
+ error: Error | null
10
+ }
11
+
12
+ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
13
+ constructor (props: ErrorBoundaryProps) {
14
+ super(props)
15
+ this.state = { error: null }
16
+ }
17
+
18
+ static getDerivedStateFromError (error: Error): ErrorBoundaryState {
19
+ return { error }
20
+ }
21
+
22
+ render () {
23
+ const { error } = this.state
24
+ const { children, fallback } = this.props
25
+
26
+ if (error) {
27
+ return createElement(fallback, { error })
28
+ }
29
+
30
+ return children
31
+ }
32
+ }
33
+
34
+ export { ErrorBoundary }
@@ -0,0 +1,328 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ background: #f8fafc;
8
+ }
9
+
10
+ .sandbox-viewer {
11
+ display: flex;
12
+ height: 100vh;
13
+ font-family:
14
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15
+ color: #1e293b;
16
+ }
17
+
18
+ .sandbox-sidebar {
19
+ width: 220px;
20
+ background: #ffffff;
21
+ border-right: 1px solid #e2e8f0;
22
+ padding: 20px 0;
23
+ overflow-y: auto;
24
+ flex-shrink: 0;
25
+ }
26
+
27
+ .sandbox-search {
28
+ padding: 0 12px 16px;
29
+ }
30
+
31
+ .sandbox-search-input {
32
+ width: 100%;
33
+ padding: 8px 12px;
34
+ font-size: 13px;
35
+ border: 1px solid #e2e8f0;
36
+ border-radius: 6px;
37
+ background: #f8fafc;
38
+ color: #1e293b;
39
+ outline: none;
40
+ transition: all 0.15s ease;
41
+ }
42
+
43
+ .sandbox-search-input::placeholder {
44
+ color: #94a3b8;
45
+ }
46
+
47
+ .sandbox-search-input:focus {
48
+ border-color: #7c3aed;
49
+ background: #ffffff;
50
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
51
+ }
52
+
53
+ .sandbox-title {
54
+ font-size: 11px;
55
+ font-weight: 600;
56
+ text-transform: uppercase;
57
+ color: #94a3b8;
58
+ padding: 0 20px 16px;
59
+ letter-spacing: 0.08em;
60
+ }
61
+
62
+ .sandbox-component {
63
+ display: block;
64
+ width: 100%;
65
+ padding: 10px 20px;
66
+ background: none;
67
+ border: none;
68
+ text-align: left;
69
+ cursor: pointer;
70
+ font-size: 14px;
71
+ color: #475569;
72
+ transition: all 0.15s ease;
73
+ }
74
+
75
+ .sandbox-component:hover {
76
+ background: #f1f5f9;
77
+ color: #1e293b;
78
+ }
79
+
80
+ .sandbox-component.selected {
81
+ background: linear-gradient(to right, #ede9fe, #f1f5f9);
82
+ color: #6d28d9;
83
+ font-weight: 500;
84
+ border-left: 3px solid #7c3aed;
85
+ padding-left: 17px;
86
+ }
87
+
88
+ .sandbox-main {
89
+ flex: 1;
90
+ display: flex;
91
+ flex-direction: column;
92
+ overflow: hidden;
93
+ background: #f8fafc;
94
+ }
95
+
96
+ .sandbox-tabs {
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 4px;
100
+ padding: 0 24px;
101
+ background: #ffffff;
102
+ border-bottom: 1px solid #e2e8f0;
103
+ min-height: 52px;
104
+ overflow-x: auto;
105
+ flex-wrap: wrap;
106
+ }
107
+
108
+ .sandbox-tab {
109
+ padding: 14px 16px;
110
+ min-width: 100px;
111
+ text-align: center;
112
+ background: none;
113
+ border: none;
114
+ border-bottom: 2px solid transparent;
115
+ cursor: pointer;
116
+ font-size: 13px;
117
+ font-weight: 500;
118
+ color: #64748b;
119
+ margin-bottom: -1px;
120
+ transition: all 0.15s ease;
121
+ border-radius: 6px 6px 0 0;
122
+ }
123
+
124
+ .sandbox-tab:hover {
125
+ color: #475569;
126
+ background: #f8fafc;
127
+ }
128
+
129
+ .sandbox-tab.selected {
130
+ color: #7c3aed;
131
+ border-bottom-color: #7c3aed;
132
+ background: none;
133
+ }
134
+
135
+ .sandbox-play {
136
+ margin-left: auto;
137
+ padding: 8px 16px;
138
+ background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
139
+ color: white;
140
+ border: none;
141
+ border-radius: 6px;
142
+ cursor: pointer;
143
+ font-size: 13px;
144
+ font-weight: 500;
145
+ transition: all 0.15s ease;
146
+ box-shadow: 0 1px 2px rgba(124, 58, 237, 0.2);
147
+ }
148
+
149
+ .sandbox-play:hover {
150
+ background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%);
151
+ box-shadow: 0 2px 4px rgba(124, 58, 237, 0.3);
152
+ transform: translateY(-1px);
153
+ }
154
+
155
+ .sandbox-play:disabled {
156
+ background: #cbd5e1;
157
+ cursor: not-allowed;
158
+ box-shadow: none;
159
+ transform: none;
160
+ }
161
+
162
+ .sandbox-preview {
163
+ flex: 1;
164
+ padding: 32px;
165
+ overflow: auto;
166
+ }
167
+
168
+ .sandbox-error {
169
+ background: #fef2f2;
170
+ border: 1px solid #fecaca;
171
+ border-radius: 8px;
172
+ padding: 20px;
173
+ font-family: system-ui, -apple-system, sans-serif;
174
+ }
175
+
176
+ .sandbox-error-title {
177
+ color: #dc2626;
178
+ font-weight: 600;
179
+ font-size: 14px;
180
+ margin-bottom: 12px;
181
+ }
182
+
183
+ .sandbox-error-message {
184
+ color: #b91c1c;
185
+ font-family: ui-monospace, monospace;
186
+ font-size: 13px;
187
+ margin: 0 0 12px;
188
+ padding: 12px;
189
+ background: #fff;
190
+ border-radius: 4px;
191
+ border: 1px solid #fecaca;
192
+ white-space: pre-wrap;
193
+ word-break: break-word;
194
+ }
195
+
196
+ .sandbox-error-stack {
197
+ color: #6b7280;
198
+ font-family: ui-monospace, monospace;
199
+ font-size: 11px;
200
+ margin: 0;
201
+ padding: 12px;
202
+ background: #fff;
203
+ border-radius: 4px;
204
+ border: 1px solid #e5e7eb;
205
+ white-space: pre-wrap;
206
+ word-break: break-word;
207
+ max-height: 300px;
208
+ overflow: auto;
209
+ }
210
+
211
+ .sandbox-inline {
212
+ flex: 1;
213
+ padding: 24px;
214
+ overflow: auto;
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 16px;
218
+ }
219
+
220
+ .sandbox-inline-scenario {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 16px;
224
+ }
225
+
226
+ .sandbox-inline-label {
227
+ font-size: 12px;
228
+ font-weight: 500;
229
+ color: #64748b;
230
+ text-transform: uppercase;
231
+ letter-spacing: 0.05em;
232
+ width: 100px;
233
+ flex-shrink: 0;
234
+ }
235
+
236
+ .sandbox-inline-preview {
237
+ flex: 1;
238
+ background: #ffffff;
239
+ border-radius: 8px;
240
+ padding: 16px;
241
+ border: 1px solid #e2e8f0;
242
+ }
243
+
244
+ .sandbox-play-slot {
245
+ width: 32px;
246
+ flex-shrink: 0;
247
+ }
248
+
249
+ .sandbox-play-inline {
250
+ width: 32px;
251
+ height: 32px;
252
+ padding: 0;
253
+ background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
254
+ color: white;
255
+ border: none;
256
+ border-radius: 6px;
257
+ cursor: pointer;
258
+ font-size: 12px;
259
+ transition: all 0.15s ease;
260
+ }
261
+
262
+ .sandbox-play-inline:hover {
263
+ background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%);
264
+ transform: translateY(-1px);
265
+ }
266
+
267
+ .sandbox-play-inline:disabled {
268
+ background: #cbd5e1;
269
+ cursor: not-allowed;
270
+ transform: none;
271
+ }
272
+
273
+ .sandbox-play-inline.error {
274
+ background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
275
+ }
276
+
277
+ .sandbox-play.error {
278
+ background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
279
+ }
280
+
281
+ .sandbox-play.error:hover {
282
+ background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
283
+ }
284
+
285
+ .sandbox-play-error {
286
+ margin-top: 16px;
287
+ background: #fef2f2;
288
+ border: 1px solid #fecaca;
289
+ border-radius: 8px;
290
+ padding: 16px;
291
+ }
292
+
293
+ .sandbox-play-error-title {
294
+ color: #dc2626;
295
+ font-weight: 600;
296
+ font-size: 13px;
297
+ margin-bottom: 8px;
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 6px;
301
+ }
302
+
303
+ .sandbox-play-error-title::before {
304
+ content: "✕";
305
+ display: inline-flex;
306
+ align-items: center;
307
+ justify-content: center;
308
+ width: 18px;
309
+ height: 18px;
310
+ background: #dc2626;
311
+ color: white;
312
+ border-radius: 50%;
313
+ font-size: 10px;
314
+ font-weight: bold;
315
+ }
316
+
317
+ .sandbox-play-error-message {
318
+ color: #b91c1c;
319
+ font-family: ui-monospace, monospace;
320
+ font-size: 12px;
321
+ margin: 0;
322
+ padding: 10px;
323
+ background: #fff;
324
+ border-radius: 4px;
325
+ border: 1px solid #fecaca;
326
+ white-space: pre-wrap;
327
+ word-break: break-word;
328
+ }
@@ -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 }