@jogak/ui 0.1.0-alpha.0 → 0.1.0-alpha.10.2
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/CHANGELOG.md +118 -0
- package/README.md +201 -15
- package/dist/app/App.d.ts +24 -1
- package/dist/components/Preview/IframeMount.d.ts +35 -0
- package/dist/components/Preview/ShadowMount.d.ts +25 -0
- package/dist/components/Preview/index.d.ts +19 -2
- package/dist/host/index.cjs +1 -0
- package/dist/host/index.d.ts +38 -1
- package/dist/host/index.mjs +40 -34
- package/dist/index.cjs +1 -0
- package/dist/index.mjs +726 -901
- package/package.json +15 -6
- package/preview-frame.html +17 -0
- package/src/app/App.tsx +189 -0
- package/src/app/main.tsx +31 -0
- package/src/app/preview-frame.tsx +61 -0
- package/src/components/Actions/index.tsx +92 -0
- package/src/components/Controls/index.tsx +190 -0
- package/src/components/Preview/IframeMount.tsx +101 -0
- package/src/components/Preview/ShadowMount.tsx +57 -0
- package/src/components/Preview/index.tsx +766 -0
- package/src/components/Sidebar/index.tsx +285 -0
- package/src/hooks/useRegistry.ts +22 -0
- package/src/index.ts +12 -0
- package/src/styles/jogak.css +128 -0
- package/src/vite-env.d.ts +30 -0
- package/dist/host/index.js +0 -1
- package/dist/index.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jogak/ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.10.2",
|
|
4
4
|
"description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jogak",
|
|
@@ -24,24 +24,31 @@
|
|
|
24
24
|
},
|
|
25
25
|
"type": "module",
|
|
26
26
|
"sideEffects": false,
|
|
27
|
-
"main": "./dist/index.
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
28
|
"module": "./dist/index.mjs",
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"exports": {
|
|
31
31
|
".": {
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
33
|
"import": "./dist/index.mjs",
|
|
34
|
-
"require": "./dist/index.
|
|
34
|
+
"require": "./dist/index.cjs"
|
|
35
35
|
},
|
|
36
36
|
"./host": {
|
|
37
37
|
"types": "./dist/host/index.d.ts",
|
|
38
38
|
"import": "./dist/host/index.mjs",
|
|
39
|
-
"require": "./dist/host/index.
|
|
39
|
+
"require": "./dist/host/index.cjs"
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"dist",
|
|
44
44
|
"index.html",
|
|
45
|
+
"preview-frame.html",
|
|
46
|
+
"src/app",
|
|
47
|
+
"src/components",
|
|
48
|
+
"src/hooks",
|
|
49
|
+
"src/styles",
|
|
50
|
+
"src/index.ts",
|
|
51
|
+
"src/vite-env.d.ts",
|
|
45
52
|
"README.md",
|
|
46
53
|
"LICENSE",
|
|
47
54
|
"CHANGELOG.md"
|
|
@@ -54,9 +61,11 @@
|
|
|
54
61
|
"registry": "https://registry.npmjs.org/"
|
|
55
62
|
},
|
|
56
63
|
"dependencies": {
|
|
64
|
+
"clsx": "^2.1.1",
|
|
57
65
|
"prism-react-renderer": "^2.4.1",
|
|
58
|
-
"
|
|
59
|
-
"@
|
|
66
|
+
"tailwindcss": "^4.0.0",
|
|
67
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
68
|
+
"@jogak/core": "0.1.0-alpha.10.2"
|
|
60
69
|
},
|
|
61
70
|
"devDependencies": {
|
|
62
71
|
"@types/node": "^20.14.0",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>jogak preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
9
|
+
html, body { margin: 0; }
|
|
10
|
+
body { font-family: system-ui, sans-serif; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="jogak-preview-root"></div>
|
|
15
|
+
<script type="module" src="/src/app/preview-frame.tsx"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/src/app/App.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
2
|
+
import { ComponentRegistry, defaultRegistry } from '@jogak/core'
|
|
3
|
+
import type { RegistryEntry, RegistryEntryMeta } from '@jogak/core'
|
|
4
|
+
import { JogakProvider } from '@jogak/core/renderers/react'
|
|
5
|
+
import { Sidebar } from '../components/Sidebar/index.js'
|
|
6
|
+
import { Preview } from '../components/Preview/index.js'
|
|
7
|
+
import type { ReactElement } from 'react'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `JogakApp` props.
|
|
11
|
+
*
|
|
12
|
+
* - `entries`: 기존 — eager 모드. 정적 빌드 / 테스트용. 새 `ComponentRegistry`에 즉시 register.
|
|
13
|
+
* - `metas` : NEW — lazy 모드. `defaultRegistry`를 그대로 사용하면서 metas를 `registerMeta`로 등록.
|
|
14
|
+
* - 둘 다 미지정 → `defaultRegistry` 그대로 사용 (인덱스 가상모듈 `import 'virtual:jogak'`이 채웠다고 가정).
|
|
15
|
+
* - 둘 다 지정 → console.warn 후 `entries` 우선 (eager 우선, breaking 회피).
|
|
16
|
+
*
|
|
17
|
+
* 외부 호환을 위해 `entries`는 optional로만 변경되며 나머지 시그니처는 그대로 유지된다.
|
|
18
|
+
*/
|
|
19
|
+
export interface JogakAppProps {
|
|
20
|
+
readonly entries?: readonly RegistryEntry[]
|
|
21
|
+
readonly metas?: readonly RegistryEntryMeta[]
|
|
22
|
+
readonly codeTheme?: string
|
|
23
|
+
/**
|
|
24
|
+
* 알파.8: Preview 영역 격리 모드. default `'iframe'`.
|
|
25
|
+
*
|
|
26
|
+
* - `'iframe'` (default) — 사용자 vite 정상 client(iframe)에 마운트. 사용자 utility 정상 컴파일.
|
|
27
|
+
* - `'shadow'` (deprecated) — ShadowRoot 안에 마운트. 사용자 utility 미적용.
|
|
28
|
+
* - `'none'` (deprecated) — chrome 같은 document에 렌더. 알파.6까지의 동작.
|
|
29
|
+
*
|
|
30
|
+
* 자세한 트레이드오프는 `@jogak/ui` README의 "previewIsolation 사용 가이드" 참조.
|
|
31
|
+
*/
|
|
32
|
+
readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
|
|
33
|
+
/**
|
|
34
|
+
* 알파.9: 어댑터 dev URL. iframe `src` base.
|
|
35
|
+
* 빈 문자열 시 fallback (jogak SPA Vite scope의 preview-frame.tsx).
|
|
36
|
+
*/
|
|
37
|
+
readonly userPreviewUrl?: string
|
|
38
|
+
/**
|
|
39
|
+
* 알파.9: iframe entry path (예: `/__jogak_preview__/index.html`).
|
|
40
|
+
*/
|
|
41
|
+
readonly previewEntryPath?: string
|
|
42
|
+
/**
|
|
43
|
+
* @deprecated 알파.10 제거 예정. `userPreviewUrl` 사용.
|
|
44
|
+
*/
|
|
45
|
+
readonly userViteUrl?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readUrlParams(): { entryId: string; jogakName: string | null } | null {
|
|
49
|
+
if (typeof window === 'undefined') return null
|
|
50
|
+
const params = new URLSearchParams(window.location.search)
|
|
51
|
+
const entryId = params.get('entry')
|
|
52
|
+
if (entryId === null) return null
|
|
53
|
+
const jogakName = params.get('jogak')
|
|
54
|
+
return { entryId, jogakName }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pushUrl(entryId: string, jogakName: string): void {
|
|
58
|
+
const params = new URLSearchParams()
|
|
59
|
+
params.set('entry', entryId)
|
|
60
|
+
params.set('jogak', jogakName)
|
|
61
|
+
window.history.pushState({}, '', `?${params.toString()}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function JogakApp({
|
|
65
|
+
entries,
|
|
66
|
+
metas,
|
|
67
|
+
codeTheme = 'vsDark',
|
|
68
|
+
previewIsolation = 'iframe',
|
|
69
|
+
userPreviewUrl = '',
|
|
70
|
+
previewEntryPath = '/__jogak_preview__/index.html',
|
|
71
|
+
userViteUrl,
|
|
72
|
+
}: JogakAppProps = {}): ReactElement {
|
|
73
|
+
// 알파.9: userViteUrl alias (deprecated). userPreviewUrl 우선.
|
|
74
|
+
const resolvedPreviewUrl = userPreviewUrl !== '' ? userPreviewUrl : (userViteUrl ?? '')
|
|
75
|
+
// ── 4가지 모드 결정 (계약 §5.2) ─────────────────────────────────────
|
|
76
|
+
// 1) entries가 주어지면: 새 ComponentRegistry에 register (eager, 기존 동작)
|
|
77
|
+
// 2) metas만 주어지면: defaultRegistry 사용 + metas를 registerMeta로 등록
|
|
78
|
+
// 3) 둘 다 미지정: defaultRegistry 그대로 (인덱스 가상모듈이 채웠다고 가정)
|
|
79
|
+
// 4) 둘 다 지정: warn 후 entries 우선 (breaking 회피)
|
|
80
|
+
const registry = useMemo(() => {
|
|
81
|
+
if (entries !== undefined) {
|
|
82
|
+
if (metas !== undefined) {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.warn(
|
|
85
|
+
'[jogak] JogakApp received both `entries` and `metas` — `entries` (eager) takes precedence.',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
const r = new ComponentRegistry()
|
|
89
|
+
for (const entry of entries) r.register(entry)
|
|
90
|
+
return r
|
|
91
|
+
}
|
|
92
|
+
if (metas !== undefined) {
|
|
93
|
+
for (const meta of metas) defaultRegistry.registerMeta(meta)
|
|
94
|
+
}
|
|
95
|
+
return defaultRegistry
|
|
96
|
+
}, [entries, metas])
|
|
97
|
+
|
|
98
|
+
// ── URL deep link 초기 상태 (계약 §5.5) ──────────────────────────────
|
|
99
|
+
// ?entry=<id>&jogak=<name>로 진입 시 그 entry로 마운트. jogak 미지정이면
|
|
100
|
+
// 사이드바가 첫 jogak을 자동 선택하지 않으므로 entry hydrate 후 보정한다.
|
|
101
|
+
const initial = useMemo(() => readUrlParams(), [])
|
|
102
|
+
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(
|
|
103
|
+
initial?.entryId ?? null,
|
|
104
|
+
)
|
|
105
|
+
const [selectedJogakName, setSelectedJogakName] = useState<string | null>(
|
|
106
|
+
initial?.jogakName ?? null,
|
|
107
|
+
)
|
|
108
|
+
const [overrideArgs, setOverrideArgs] = useState<Readonly<Record<string, unknown>>>({})
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const handlePopState = (): void => {
|
|
112
|
+
const parsed = readUrlParams()
|
|
113
|
+
if (parsed !== null) {
|
|
114
|
+
setSelectedEntryId(parsed.entryId)
|
|
115
|
+
setSelectedJogakName(parsed.jogakName)
|
|
116
|
+
setOverrideArgs({})
|
|
117
|
+
} else {
|
|
118
|
+
setSelectedEntryId(null)
|
|
119
|
+
setSelectedJogakName(null)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
window.addEventListener('popstate', handlePopState)
|
|
123
|
+
return () => { window.removeEventListener('popstate', handlePopState) }
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
const handleSelect = useCallback((entryId: string, jogakName: string) => {
|
|
127
|
+
setSelectedEntryId(entryId)
|
|
128
|
+
setSelectedJogakName(jogakName)
|
|
129
|
+
setOverrideArgs({})
|
|
130
|
+
pushUrl(entryId, jogakName)
|
|
131
|
+
}, [])
|
|
132
|
+
|
|
133
|
+
const handleResolveJogak = useCallback((entryId: string, jogakName: string) => {
|
|
134
|
+
// Preview가 entry를 hydrate한 뒤 jogakName이 비어있을 때 첫 jogak로 보정.
|
|
135
|
+
setSelectedEntryId((prevId) => (prevId === entryId ? entryId : prevId))
|
|
136
|
+
setSelectedJogakName((prev) => prev ?? jogakName)
|
|
137
|
+
if (typeof window !== 'undefined') {
|
|
138
|
+
// URL에 jogak이 누락된 경우만 보정 (사용자 history는 건드리지 않음 — replaceState).
|
|
139
|
+
const params = new URLSearchParams(window.location.search)
|
|
140
|
+
if (params.get('entry') === entryId && params.get('jogak') === null) {
|
|
141
|
+
params.set('jogak', jogakName)
|
|
142
|
+
window.history.replaceState({}, '', `?${params.toString()}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}, [])
|
|
146
|
+
|
|
147
|
+
const handleArgChange = useCallback((key: string, value: unknown) => {
|
|
148
|
+
setOverrideArgs((prev) => ({ ...prev, [key]: value }))
|
|
149
|
+
}, [])
|
|
150
|
+
|
|
151
|
+
const handleReset = useCallback(() => {
|
|
152
|
+
setOverrideArgs({})
|
|
153
|
+
}, [])
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<JogakProvider registry={registry}>
|
|
157
|
+
<div
|
|
158
|
+
data-jogak-shell
|
|
159
|
+
className="jogak:grid jogak:grid-cols-[260px_1fr] jogak:h-dvh jogak:overflow-hidden"
|
|
160
|
+
>
|
|
161
|
+
<Sidebar
|
|
162
|
+
selectedEntryId={selectedEntryId}
|
|
163
|
+
selectedJogakName={selectedJogakName}
|
|
164
|
+
onSelect={handleSelect}
|
|
165
|
+
/>
|
|
166
|
+
<main className="jogak:overflow-hidden jogak:min-h-0">
|
|
167
|
+
{selectedEntryId !== null ? (
|
|
168
|
+
<Preview
|
|
169
|
+
entryId={selectedEntryId}
|
|
170
|
+
jogakName={selectedJogakName}
|
|
171
|
+
overrideArgs={overrideArgs}
|
|
172
|
+
onArgChange={handleArgChange}
|
|
173
|
+
onReset={handleReset}
|
|
174
|
+
codeTheme={codeTheme}
|
|
175
|
+
onResolveJogak={handleResolveJogak}
|
|
176
|
+
previewIsolation={previewIsolation}
|
|
177
|
+
userPreviewUrl={resolvedPreviewUrl}
|
|
178
|
+
previewEntryPath={previewEntryPath}
|
|
179
|
+
/>
|
|
180
|
+
) : (
|
|
181
|
+
<div className="jogak:flex jogak:items-center jogak:justify-center jogak:h-full jogak:text-[var(--jogak-color-fg-subtle)]">
|
|
182
|
+
Select a component from the sidebar
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</main>
|
|
186
|
+
</div>
|
|
187
|
+
</JogakProvider>
|
|
188
|
+
)
|
|
189
|
+
}
|
package/src/app/main.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import 'virtual:jogak'
|
|
4
|
+
import {
|
|
5
|
+
_jogakCodeTheme,
|
|
6
|
+
_jogakPreviewIsolation,
|
|
7
|
+
_jogakUserPreviewUrl,
|
|
8
|
+
_jogakPreviewEntryPath,
|
|
9
|
+
} from 'virtual:jogak'
|
|
10
|
+
import '../styles/jogak.css'
|
|
11
|
+
import { JogakApp } from './App.js'
|
|
12
|
+
|
|
13
|
+
// 알파.9: 사용자 globalCss는 어댑터 scope(iframe entry)에서 처리되므로 jogak SPA outer
|
|
14
|
+
// document에는 import하지 않는다. 'none' 모드(deprecated)에서만 outer inject.
|
|
15
|
+
if (_jogakPreviewIsolation === 'none') {
|
|
16
|
+
await import('virtual:jogak/global-css')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rootEl = document.getElementById('root')
|
|
20
|
+
if (rootEl === null) throw new Error('#root element not found')
|
|
21
|
+
|
|
22
|
+
createRoot(rootEl).render(
|
|
23
|
+
<StrictMode>
|
|
24
|
+
<JogakApp
|
|
25
|
+
codeTheme={_jogakCodeTheme}
|
|
26
|
+
previewIsolation={_jogakPreviewIsolation}
|
|
27
|
+
userPreviewUrl={_jogakUserPreviewUrl}
|
|
28
|
+
previewEntryPath={_jogakPreviewEntryPath}
|
|
29
|
+
/>
|
|
30
|
+
</StrictMode>,
|
|
31
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 알파.9: standalone-adapter 또는 fallback 시 사용되는 same-origin iframe entry.
|
|
3
|
+
*
|
|
4
|
+
* `IframeMount`는 알파.9에서 postMessage 프로토콜로 통일됐다 (cross-origin 어댑터와 동일).
|
|
5
|
+
* preview-frame.tsx도 같은 프로토콜을 따라야 한다 — 부모는 `jogak:setProps` 메시지를,
|
|
6
|
+
* iframe은 `jogak:ready` / `jogak:rendered` / `jogak:error`를 emit한다.
|
|
7
|
+
*
|
|
8
|
+
* jogak host vite scope에서 동작하므로 `virtual:jogak` (registry metas + entry loader)와
|
|
9
|
+
* `virtual:jogak/global-css` (사용자 globalCss) 가상 모듈을 그대로 사용한다.
|
|
10
|
+
*/
|
|
11
|
+
import { reactAdapter } from '@jogak/core/renderers/react'
|
|
12
|
+
import { defaultRegistry } from '@jogak/core'
|
|
13
|
+
import 'virtual:jogak'
|
|
14
|
+
import 'virtual:jogak/global-css'
|
|
15
|
+
|
|
16
|
+
const rootEl = document.getElementById('jogak-preview-root')
|
|
17
|
+
if (rootEl === null) throw new Error('#jogak-preview-root not found')
|
|
18
|
+
|
|
19
|
+
let currentContainer: HTMLDivElement | null = null
|
|
20
|
+
|
|
21
|
+
async function renderEntry(
|
|
22
|
+
entryId: string,
|
|
23
|
+
args: Readonly<Record<string, unknown>>,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const entry = await defaultRegistry.requestEntry(entryId)
|
|
26
|
+
if (currentContainer === null) {
|
|
27
|
+
currentContainer = document.createElement('div')
|
|
28
|
+
rootEl?.replaceChildren(currentContainer)
|
|
29
|
+
}
|
|
30
|
+
reactAdapter.render(entry, args, currentContainer)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function unmount(): void {
|
|
34
|
+
if (currentContainer !== null) {
|
|
35
|
+
reactAdapter.unmount(currentContainer)
|
|
36
|
+
currentContainer = null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
41
|
+
const data = event.data as { type?: unknown; entryId?: unknown; args?: unknown } | null
|
|
42
|
+
if (data === null || typeof data !== 'object') return
|
|
43
|
+
if (data.type === 'jogak:setProps' && typeof data.entryId === 'string') {
|
|
44
|
+
const args = (data.args ?? {}) as Readonly<Record<string, unknown>>
|
|
45
|
+
void renderEntry(data.entryId, args)
|
|
46
|
+
.then(() => {
|
|
47
|
+
window.parent.postMessage(
|
|
48
|
+
{ type: 'jogak:rendered', entryId: data.entryId },
|
|
49
|
+
'*',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
.catch((err: unknown) => {
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
54
|
+
window.parent.postMessage({ type: 'jogak:error', message }, '*')
|
|
55
|
+
})
|
|
56
|
+
} else if (data.type === 'jogak:unmount') {
|
|
57
|
+
unmount()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
window.parent.postMessage({ type: 'jogak:ready' }, '*')
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { defaultActionChannel } from '@jogak/core'
|
|
5
|
+
import type { ActionLog } from '@jogak/core'
|
|
6
|
+
|
|
7
|
+
function formatArgs(args: readonly unknown[]): string {
|
|
8
|
+
if (args.length === 0) return '()'
|
|
9
|
+
try {
|
|
10
|
+
return args
|
|
11
|
+
.map((arg) => {
|
|
12
|
+
if (arg === null) return 'null'
|
|
13
|
+
if (arg === undefined) return 'undefined'
|
|
14
|
+
if (typeof arg === 'function') return '[Function]'
|
|
15
|
+
if (typeof arg === 'object') {
|
|
16
|
+
const ctorName =
|
|
17
|
+
(arg as { constructor?: { name?: string } }).constructor?.name ?? 'Object'
|
|
18
|
+
if (ctorName !== 'Object' && ctorName !== 'Array') return `[${ctorName}]`
|
|
19
|
+
return JSON.stringify(arg)
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(arg)
|
|
22
|
+
})
|
|
23
|
+
.join(', ')
|
|
24
|
+
} catch {
|
|
25
|
+
return '[unserializable]'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatTime(ts: number): string {
|
|
30
|
+
const d = new Date(ts)
|
|
31
|
+
const hh = d.getHours().toString().padStart(2, '0')
|
|
32
|
+
const mm = d.getMinutes().toString().padStart(2, '0')
|
|
33
|
+
const ss = d.getSeconds().toString().padStart(2, '0')
|
|
34
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0')
|
|
35
|
+
return `${hh}:${mm}:${ss}.${ms}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Actions(): ReactElement {
|
|
39
|
+
const [logs, setLogs] = useState<readonly ActionLog[]>(() => defaultActionChannel.getLogs())
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
return defaultActionChannel.subscribe(setLogs)
|
|
43
|
+
}, [])
|
|
44
|
+
|
|
45
|
+
const isEmpty = logs.length === 0
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="jogak:h-full jogak:flex jogak:flex-col">
|
|
49
|
+
<div className="jogak:px-5 jogak:py-1.5 jogak:text-[11px] jogak:font-bold jogak:text-[var(--jogak-color-fg-subtle)] jogak:uppercase jogak:tracking-[0.08em] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:bg-[var(--jogak-color-bg-subtle)] jogak:flex jogak:items-center jogak:justify-between jogak:shrink-0">
|
|
50
|
+
<span>Actions {logs.length > 0 && `(${logs.length.toString()})`}</span>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => { defaultActionChannel.clear() }}
|
|
54
|
+
disabled={isEmpty}
|
|
55
|
+
className={clsx(
|
|
56
|
+
'jogak:text-[10px] jogak:font-semibold jogak:px-2 jogak:py-0.5 jogak:border jogak:border-[var(--jogak-color-border-strong)] jogak:rounded-[var(--jogak-radius-sm)] jogak:bg-[var(--jogak-color-bg)] jogak:normal-case jogak:tracking-normal',
|
|
57
|
+
isEmpty
|
|
58
|
+
? 'jogak:text-[var(--jogak-color-fg-subtle)] jogak:cursor-default'
|
|
59
|
+
: 'jogak:text-[var(--jogak-color-fg)] jogak:cursor-pointer',
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
Clear
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="jogak:flex-1 jogak:overflow-auto">
|
|
67
|
+
{isEmpty ? (
|
|
68
|
+
<div className="jogak:px-5 jogak:py-3 jogak:text-[var(--jogak-color-fg-subtle)] jogak:text-[13px] jogak:leading-none">
|
|
69
|
+
함수 prop이 호출되면 여기에 기록됩니다
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
<ul className="jogak:list-none jogak:m-0 jogak:p-0 jogak:font-[family-name:var(--jogak-font-mono)] jogak:text-[12px]">
|
|
73
|
+
{logs.map((log) => (
|
|
74
|
+
<li
|
|
75
|
+
key={log.id}
|
|
76
|
+
className="jogak:flex jogak:items-baseline jogak:gap-[10px] jogak:px-5 jogak:py-1.5 jogak:border-b jogak:border-[var(--jogak-color-border-muted)]"
|
|
77
|
+
>
|
|
78
|
+
<span className="jogak:text-[var(--jogak-color-fg-subtle)] jogak:text-[11px] jogak:min-w-[92px]">
|
|
79
|
+
{formatTime(log.timestamp)}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="jogak:text-[var(--jogak-color-violet)] jogak:font-semibold">{log.name}</span>
|
|
82
|
+
<span className="jogak:text-[var(--jogak-color-fg)] jogak:break-all jogak:flex-1">
|
|
83
|
+
({formatArgs(log.args)})
|
|
84
|
+
</span>
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ReactElement, ChangeEvent } from 'react'
|
|
2
|
+
import clsx from 'clsx'
|
|
3
|
+
import type { ArgType } from '@jogak/core'
|
|
4
|
+
|
|
5
|
+
export interface ControlsProps {
|
|
6
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
7
|
+
readonly argTypes: Readonly<Record<string, ArgType>>
|
|
8
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type ControlKind = 'boolean' | 'number' | 'text' | 'select' | 'action' | 'json'
|
|
12
|
+
|
|
13
|
+
function resolveControlKind(value: unknown, argType: ArgType | undefined): ControlKind {
|
|
14
|
+
const ctrl = argType?.control
|
|
15
|
+
const isAction = argType?.action !== undefined && argType.action !== false
|
|
16
|
+
const isFunctionType = argType?.type === 'function' || typeof value === 'function'
|
|
17
|
+
|
|
18
|
+
if (isAction || isFunctionType) return 'action'
|
|
19
|
+
if (ctrl === 'boolean' || typeof value === 'boolean') return 'boolean'
|
|
20
|
+
if (ctrl === 'number' || ctrl === 'range' || typeof value === 'number') return 'number'
|
|
21
|
+
if (
|
|
22
|
+
ctrl === 'select' ||
|
|
23
|
+
ctrl === 'radio' ||
|
|
24
|
+
(argType?.options !== undefined && argType.options.length > 0)
|
|
25
|
+
)
|
|
26
|
+
return 'select'
|
|
27
|
+
if (ctrl === 'text' || ctrl === 'color' || typeof value === 'string') return 'text'
|
|
28
|
+
return 'json'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ControlInputProps {
|
|
32
|
+
readonly argKey: string
|
|
33
|
+
readonly value: unknown
|
|
34
|
+
readonly argType: ArgType | undefined
|
|
35
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* input/select 공용 className — §6.10 의 inputClass 상수.
|
|
40
|
+
*
|
|
41
|
+
* `<select>` 에 `appearance-none` 을 일부러 적용하지 않는다 (UA dropdown 화살표 보존, §3.5).
|
|
42
|
+
* alpha.4 baseline 이 UA 기본 select 화살표를 캡처한 상태라 픽셀 동등을 위해 유지.
|
|
43
|
+
*/
|
|
44
|
+
const inputClass =
|
|
45
|
+
'jogak:px-2 jogak:py-1 ' +
|
|
46
|
+
'jogak:border jogak:border-[var(--jogak-color-border-strong)] ' +
|
|
47
|
+
'jogak:rounded-[var(--jogak-radius-md)] ' +
|
|
48
|
+
'jogak:text-[13px] ' +
|
|
49
|
+
'jogak:w-full jogak:max-w-[280px]'
|
|
50
|
+
|
|
51
|
+
/** th className 상수 — Controls table header cell. */
|
|
52
|
+
const thClass =
|
|
53
|
+
'jogak:px-5 jogak:py-1.5 ' +
|
|
54
|
+
'jogak:text-left ' +
|
|
55
|
+
'jogak:text-[var(--jogak-color-fg-muted)] ' +
|
|
56
|
+
'jogak:font-medium ' +
|
|
57
|
+
'jogak:text-[12px] ' +
|
|
58
|
+
'jogak:border-b jogak:border-[var(--jogak-color-border)]'
|
|
59
|
+
|
|
60
|
+
/** td className 상수 — Controls table body cell. */
|
|
61
|
+
const tdClass =
|
|
62
|
+
'jogak:px-5 jogak:py-2 ' +
|
|
63
|
+
'jogak:align-middle ' +
|
|
64
|
+
'jogak:border-b jogak:border-[var(--jogak-color-border-muted)]'
|
|
65
|
+
|
|
66
|
+
function ControlInput({ argKey, value, argType, onArgChange }: ControlInputProps): ReactElement {
|
|
67
|
+
const kind = resolveControlKind(value, argType)
|
|
68
|
+
|
|
69
|
+
switch (kind) {
|
|
70
|
+
case 'boolean':
|
|
71
|
+
return (
|
|
72
|
+
<input
|
|
73
|
+
type="checkbox"
|
|
74
|
+
checked={value === true}
|
|
75
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
76
|
+
onArgChange(argKey, e.target.checked)
|
|
77
|
+
}}
|
|
78
|
+
className="jogak:cursor-pointer jogak:w-4 jogak:h-4 jogak:accent-[var(--jogak-color-accent)]"
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
case 'number':
|
|
82
|
+
return (
|
|
83
|
+
<input
|
|
84
|
+
type="number"
|
|
85
|
+
value={typeof value === 'number' ? value : ''}
|
|
86
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
87
|
+
onArgChange(argKey, e.target.valueAsNumber)
|
|
88
|
+
}}
|
|
89
|
+
className={inputClass}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
case 'select': {
|
|
93
|
+
const options = argType?.options ?? []
|
|
94
|
+
return (
|
|
95
|
+
<select
|
|
96
|
+
value={String(value ?? '')}
|
|
97
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
98
|
+
onArgChange(argKey, e.target.value)
|
|
99
|
+
}}
|
|
100
|
+
className={inputClass}
|
|
101
|
+
>
|
|
102
|
+
{options.map((opt) => (
|
|
103
|
+
<option key={String(opt)} value={String(opt)}>
|
|
104
|
+
{String(opt)}
|
|
105
|
+
</option>
|
|
106
|
+
))}
|
|
107
|
+
</select>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
case 'text':
|
|
111
|
+
return (
|
|
112
|
+
<input
|
|
113
|
+
type="text"
|
|
114
|
+
value={typeof value === 'string' ? value : String(value ?? '')}
|
|
115
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
116
|
+
onArgChange(argKey, e.target.value)
|
|
117
|
+
}}
|
|
118
|
+
className={inputClass}
|
|
119
|
+
/>
|
|
120
|
+
)
|
|
121
|
+
case 'action':
|
|
122
|
+
return (
|
|
123
|
+
<span className="jogak:inline-block jogak:px-2 jogak:py-0.5 jogak:text-[11px] jogak:font-semibold jogak:text-[var(--jogak-color-violet)] jogak:bg-[var(--jogak-color-violet-bg)] jogak:border jogak:border-[var(--jogak-color-violet-border)] jogak:rounded-[var(--jogak-radius-md)] jogak:font-[family-name:var(--jogak-font-mono)] jogak:leading-none">
|
|
124
|
+
(action)
|
|
125
|
+
</span>
|
|
126
|
+
)
|
|
127
|
+
case 'json':
|
|
128
|
+
return (
|
|
129
|
+
<code className="jogak:text-[12px] jogak:text-[var(--jogak-color-fg-muted)] jogak:font-[family-name:var(--jogak-font-mono)]">
|
|
130
|
+
{JSON.stringify(value)}
|
|
131
|
+
</code>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactElement {
|
|
137
|
+
const keys = Array.from(new Set([...Object.keys(args), ...Object.keys(argTypes)]))
|
|
138
|
+
const entries = keys.map((k) => [k, args[k]] as const)
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="jogak:border-t-2 jogak:border-[var(--jogak-color-border)]">
|
|
142
|
+
<div className="jogak:px-5 jogak:py-1.5 jogak:text-[11px] jogak:font-bold jogak:text-[var(--jogak-color-fg-subtle)] jogak:uppercase jogak:tracking-[0.08em] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:bg-[var(--jogak-color-bg-subtle)]">
|
|
143
|
+
Controls
|
|
144
|
+
</div>
|
|
145
|
+
{entries.length === 0 ? (
|
|
146
|
+
<div className="jogak:px-5 jogak:py-3 jogak:text-[var(--jogak-color-fg-subtle)] jogak:text-[13px]">
|
|
147
|
+
No args defined
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<table className="jogak:w-full jogak:border-collapse jogak:text-[13px]">
|
|
151
|
+
<thead>
|
|
152
|
+
<tr>
|
|
153
|
+
<th className={thClass}>Name</th>
|
|
154
|
+
<th className={thClass}>Control</th>
|
|
155
|
+
<th className={thClass}>Description</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
{entries.map(([key, value]) => {
|
|
160
|
+
const argType = argTypes[key]
|
|
161
|
+
return (
|
|
162
|
+
<tr key={key}>
|
|
163
|
+
<td
|
|
164
|
+
className={clsx(
|
|
165
|
+
tdClass,
|
|
166
|
+
'jogak:font-[family-name:var(--jogak-font-mono)] jogak:text-[12px] jogak:text-[var(--jogak-color-fg)] jogak:whitespace-nowrap',
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{key}
|
|
170
|
+
</td>
|
|
171
|
+
<td className={tdClass}>
|
|
172
|
+
<ControlInput
|
|
173
|
+
argKey={key}
|
|
174
|
+
value={value}
|
|
175
|
+
argType={argType}
|
|
176
|
+
onArgChange={onArgChange}
|
|
177
|
+
/>
|
|
178
|
+
</td>
|
|
179
|
+
<td className={clsx(tdClass, 'jogak:text-[var(--jogak-color-fg-subtle)]')}>
|
|
180
|
+
{argType?.description ?? ''}
|
|
181
|
+
</td>
|
|
182
|
+
</tr>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
</tbody>
|
|
186
|
+
</table>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|