@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 +17 -7
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@northlight/ui",
|
|
3
|
-
"version": "2.
|
|
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": "
|
|
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": "
|
|
121
|
+
"gitHead": "fd559db249b74856dcf44ba2615913607647637a"
|
|
112
122
|
}
|
|
@@ -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,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 }
|