@jogak/ui 0.1.0-alpha.0 → 0.1.0-alpha.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 +14 -0
- package/package.json +8 -3
- package/src/app/App.tsx +169 -0
- package/src/app/main.tsx +14 -0
- package/src/components/Actions/index.tsx +122 -0
- package/src/components/Controls/index.tsx +211 -0
- package/src/components/Preview/index.tsx +739 -0
- package/src/components/Sidebar/index.tsx +312 -0
- package/src/hooks/useRegistry.ts +22 -0
- package/src/index.ts +12 -0
- package/src/vite-env.d.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to Jogak packages are documented here. The repository follow
|
|
|
5
5
|
|
|
6
6
|
Version numbers apply to all packages in the workspace (synchronized release).
|
|
7
7
|
|
|
8
|
+
## [0.1.0-alpha.2] — 2026-05-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`@jogak/ui`**: Published package was missing `src/app/main.tsx` (referenced by `index.html` as the SPA entry), causing `jogak dev` to 404 on the entry script and `jogak build` to fail with `Failed to resolve /src/app/main.tsx`. The `files` field in `package.json` only included `dist/`, but `runHost` uses the package's `index.html` as the Vite root and resolves `/src/app/main.tsx` from source. Added `src/app`, `src/components`, `src/hooks`, `src/index.ts`, `src/vite-env.d.ts` to `files`. Internal `src/host` and `src/examples` remain excluded (host is consumed via the published `dist/host` build; examples are repository-only demos).
|
|
13
|
+
|
|
14
|
+
Other packages: no source changes; version bumped to keep the workspace synchronized.
|
|
15
|
+
|
|
16
|
+
## [0.1.0-alpha.1] — 2026-05-07
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **CI**: Switched npm publish authentication from `NPM_TOKEN` secret to npm Trusted Publisher (GitHub Actions OIDC). The release workflow no longer reads `NODE_AUTH_TOKEN`; identity is verified via the OIDC token issued for `id-token: write`. No functional changes to package source code.
|
|
21
|
+
|
|
8
22
|
## [0.1.0-alpha.0] — 2026-05-07
|
|
9
23
|
|
|
10
24
|
First public preview release. API is not yet stable.
|
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.2",
|
|
4
4
|
"description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jogak",
|
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"dist",
|
|
44
44
|
"index.html",
|
|
45
|
+
"src/app",
|
|
46
|
+
"src/components",
|
|
47
|
+
"src/hooks",
|
|
48
|
+
"src/index.ts",
|
|
49
|
+
"src/vite-env.d.ts",
|
|
45
50
|
"README.md",
|
|
46
51
|
"LICENSE",
|
|
47
52
|
"CHANGELOG.md"
|
|
@@ -55,8 +60,8 @@
|
|
|
55
60
|
},
|
|
56
61
|
"dependencies": {
|
|
57
62
|
"prism-react-renderer": "^2.4.1",
|
|
58
|
-
"@jogak/core": "0.1.0-alpha.
|
|
59
|
-
"@jogak/react": "0.1.0-alpha.
|
|
63
|
+
"@jogak/core": "0.1.0-alpha.2",
|
|
64
|
+
"@jogak/react": "0.1.0-alpha.2"
|
|
60
65
|
},
|
|
61
66
|
"devDependencies": {
|
|
62
67
|
"@types/node": "^20.14.0",
|
package/src/app/App.tsx
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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/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
|
+
|
|
25
|
+
function readUrlParams(): { entryId: string; jogakName: string | null } | null {
|
|
26
|
+
if (typeof window === 'undefined') return null
|
|
27
|
+
const params = new URLSearchParams(window.location.search)
|
|
28
|
+
const entryId = params.get('entry')
|
|
29
|
+
if (entryId === null) return null
|
|
30
|
+
const jogakName = params.get('jogak')
|
|
31
|
+
return { entryId, jogakName }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pushUrl(entryId: string, jogakName: string): void {
|
|
35
|
+
const params = new URLSearchParams()
|
|
36
|
+
params.set('entry', entryId)
|
|
37
|
+
params.set('jogak', jogakName)
|
|
38
|
+
window.history.pushState({}, '', `?${params.toString()}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function JogakApp({
|
|
42
|
+
entries,
|
|
43
|
+
metas,
|
|
44
|
+
codeTheme = 'vsDark',
|
|
45
|
+
}: JogakAppProps = {}): ReactElement {
|
|
46
|
+
// ── 4가지 모드 결정 (계약 §5.2) ─────────────────────────────────────
|
|
47
|
+
// 1) entries가 주어지면: 새 ComponentRegistry에 register (eager, 기존 동작)
|
|
48
|
+
// 2) metas만 주어지면: defaultRegistry 사용 + metas를 registerMeta로 등록
|
|
49
|
+
// 3) 둘 다 미지정: defaultRegistry 그대로 (인덱스 가상모듈이 채웠다고 가정)
|
|
50
|
+
// 4) 둘 다 지정: warn 후 entries 우선 (breaking 회피)
|
|
51
|
+
const registry = useMemo(() => {
|
|
52
|
+
if (entries !== undefined) {
|
|
53
|
+
if (metas !== undefined) {
|
|
54
|
+
// eslint-disable-next-line no-console
|
|
55
|
+
console.warn(
|
|
56
|
+
'[jogak] JogakApp received both `entries` and `metas` — `entries` (eager) takes precedence.',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
const r = new ComponentRegistry()
|
|
60
|
+
for (const entry of entries) r.register(entry)
|
|
61
|
+
return r
|
|
62
|
+
}
|
|
63
|
+
if (metas !== undefined) {
|
|
64
|
+
for (const meta of metas) defaultRegistry.registerMeta(meta)
|
|
65
|
+
}
|
|
66
|
+
return defaultRegistry
|
|
67
|
+
}, [entries, metas])
|
|
68
|
+
|
|
69
|
+
// ── URL deep link 초기 상태 (계약 §5.5) ──────────────────────────────
|
|
70
|
+
// ?entry=<id>&jogak=<name>로 진입 시 그 entry로 마운트. jogak 미지정이면
|
|
71
|
+
// 사이드바가 첫 jogak을 자동 선택하지 않으므로 entry hydrate 후 보정한다.
|
|
72
|
+
const initial = useMemo(() => readUrlParams(), [])
|
|
73
|
+
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(
|
|
74
|
+
initial?.entryId ?? null,
|
|
75
|
+
)
|
|
76
|
+
const [selectedJogakName, setSelectedJogakName] = useState<string | null>(
|
|
77
|
+
initial?.jogakName ?? null,
|
|
78
|
+
)
|
|
79
|
+
const [overrideArgs, setOverrideArgs] = useState<Readonly<Record<string, unknown>>>({})
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const handlePopState = (): void => {
|
|
83
|
+
const parsed = readUrlParams()
|
|
84
|
+
if (parsed !== null) {
|
|
85
|
+
setSelectedEntryId(parsed.entryId)
|
|
86
|
+
setSelectedJogakName(parsed.jogakName)
|
|
87
|
+
setOverrideArgs({})
|
|
88
|
+
} else {
|
|
89
|
+
setSelectedEntryId(null)
|
|
90
|
+
setSelectedJogakName(null)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
window.addEventListener('popstate', handlePopState)
|
|
94
|
+
return () => { window.removeEventListener('popstate', handlePopState) }
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
const handleSelect = useCallback((entryId: string, jogakName: string) => {
|
|
98
|
+
setSelectedEntryId(entryId)
|
|
99
|
+
setSelectedJogakName(jogakName)
|
|
100
|
+
setOverrideArgs({})
|
|
101
|
+
pushUrl(entryId, jogakName)
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const handleResolveJogak = useCallback((entryId: string, jogakName: string) => {
|
|
105
|
+
// Preview가 entry를 hydrate한 뒤 jogakName이 비어있을 때 첫 jogak로 보정.
|
|
106
|
+
setSelectedEntryId((prevId) => (prevId === entryId ? entryId : prevId))
|
|
107
|
+
setSelectedJogakName((prev) => prev ?? jogakName)
|
|
108
|
+
if (typeof window !== 'undefined') {
|
|
109
|
+
// URL에 jogak이 누락된 경우만 보정 (사용자 history는 건드리지 않음 — replaceState).
|
|
110
|
+
const params = new URLSearchParams(window.location.search)
|
|
111
|
+
if (params.get('entry') === entryId && params.get('jogak') === null) {
|
|
112
|
+
params.set('jogak', jogakName)
|
|
113
|
+
window.history.replaceState({}, '', `?${params.toString()}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
const handleArgChange = useCallback((key: string, value: unknown) => {
|
|
119
|
+
setOverrideArgs((prev) => ({ ...prev, [key]: value }))
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
const handleReset = useCallback(() => {
|
|
123
|
+
setOverrideArgs({})
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<JogakProvider registry={registry}>
|
|
128
|
+
<div
|
|
129
|
+
style={{
|
|
130
|
+
display: 'grid',
|
|
131
|
+
gridTemplateColumns: '260px 1fr',
|
|
132
|
+
height: '100dvh',
|
|
133
|
+
overflow: 'hidden',
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<Sidebar
|
|
137
|
+
selectedEntryId={selectedEntryId}
|
|
138
|
+
selectedJogakName={selectedJogakName}
|
|
139
|
+
onSelect={handleSelect}
|
|
140
|
+
/>
|
|
141
|
+
<main style={{ overflow: 'hidden', minHeight: 0 }}>
|
|
142
|
+
{selectedEntryId !== null ? (
|
|
143
|
+
<Preview
|
|
144
|
+
entryId={selectedEntryId}
|
|
145
|
+
jogakName={selectedJogakName}
|
|
146
|
+
overrideArgs={overrideArgs}
|
|
147
|
+
onArgChange={handleArgChange}
|
|
148
|
+
onReset={handleReset}
|
|
149
|
+
codeTheme={codeTheme}
|
|
150
|
+
onResolveJogak={handleResolveJogak}
|
|
151
|
+
/>
|
|
152
|
+
) : (
|
|
153
|
+
<div
|
|
154
|
+
style={{
|
|
155
|
+
display: 'flex',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'center',
|
|
158
|
+
height: '100%',
|
|
159
|
+
color: '#9ca3af',
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
Select a component from the sidebar
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</main>
|
|
166
|
+
</div>
|
|
167
|
+
</JogakProvider>
|
|
168
|
+
)
|
|
169
|
+
}
|
package/src/app/main.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import 'virtual:jogak'
|
|
4
|
+
import { _jogakCodeTheme } from 'virtual:jogak'
|
|
5
|
+
import { JogakApp } from './App.js'
|
|
6
|
+
|
|
7
|
+
const rootEl = document.getElementById('root')
|
|
8
|
+
if (rootEl === null) throw new Error('#root element not found')
|
|
9
|
+
|
|
10
|
+
createRoot(rootEl).render(
|
|
11
|
+
<StrictMode>
|
|
12
|
+
<JogakApp codeTheme={_jogakCodeTheme} />
|
|
13
|
+
</StrictMode>,
|
|
14
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import { defaultActionChannel } from '@jogak/core'
|
|
4
|
+
import type { ActionLog } from '@jogak/core'
|
|
5
|
+
|
|
6
|
+
function formatArgs(args: readonly unknown[]): string {
|
|
7
|
+
if (args.length === 0) return '()'
|
|
8
|
+
try {
|
|
9
|
+
return args
|
|
10
|
+
.map((arg) => {
|
|
11
|
+
if (arg === null) return 'null'
|
|
12
|
+
if (arg === undefined) return 'undefined'
|
|
13
|
+
if (typeof arg === 'function') return '[Function]'
|
|
14
|
+
if (typeof arg === 'object') {
|
|
15
|
+
const ctorName =
|
|
16
|
+
(arg as { constructor?: { name?: string } }).constructor?.name ?? 'Object'
|
|
17
|
+
if (ctorName !== 'Object' && ctorName !== 'Array') return `[${ctorName}]`
|
|
18
|
+
return JSON.stringify(arg)
|
|
19
|
+
}
|
|
20
|
+
return JSON.stringify(arg)
|
|
21
|
+
})
|
|
22
|
+
.join(', ')
|
|
23
|
+
} catch {
|
|
24
|
+
return '[unserializable]'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatTime(ts: number): string {
|
|
29
|
+
const d = new Date(ts)
|
|
30
|
+
const hh = d.getHours().toString().padStart(2, '0')
|
|
31
|
+
const mm = d.getMinutes().toString().padStart(2, '0')
|
|
32
|
+
const ss = d.getSeconds().toString().padStart(2, '0')
|
|
33
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0')
|
|
34
|
+
return `${hh}:${mm}:${ss}.${ms}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Actions(): ReactElement {
|
|
38
|
+
const [logs, setLogs] = useState<readonly ActionLog[]>(() => defaultActionChannel.getLogs())
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
return defaultActionChannel.subscribe(setLogs)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
46
|
+
<div
|
|
47
|
+
style={{
|
|
48
|
+
padding: '6px 20px',
|
|
49
|
+
fontSize: 11,
|
|
50
|
+
fontWeight: 700,
|
|
51
|
+
color: '#9ca3af',
|
|
52
|
+
textTransform: 'uppercase',
|
|
53
|
+
letterSpacing: '0.08em',
|
|
54
|
+
borderBottom: '1px solid #e5e7eb',
|
|
55
|
+
background: '#f9fafb',
|
|
56
|
+
display: 'flex',
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
justifyContent: 'space-between',
|
|
59
|
+
flexShrink: 0,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<span>Actions {logs.length > 0 && `(${logs.length.toString()})`}</span>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => { defaultActionChannel.clear() }}
|
|
66
|
+
disabled={logs.length === 0}
|
|
67
|
+
style={{
|
|
68
|
+
fontSize: 10,
|
|
69
|
+
fontWeight: 600,
|
|
70
|
+
padding: '2px 8px',
|
|
71
|
+
border: '1px solid #d1d5db',
|
|
72
|
+
borderRadius: 3,
|
|
73
|
+
background: '#fff',
|
|
74
|
+
color: logs.length === 0 ? '#9ca3af' : '#374151',
|
|
75
|
+
cursor: logs.length === 0 ? 'default' : 'pointer',
|
|
76
|
+
textTransform: 'none',
|
|
77
|
+
letterSpacing: 0,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
Clear
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div style={{ flex: 1, overflow: 'auto' }}>
|
|
85
|
+
{logs.length === 0 ? (
|
|
86
|
+
<div
|
|
87
|
+
style={{
|
|
88
|
+
padding: '12px 20px',
|
|
89
|
+
color: '#9ca3af',
|
|
90
|
+
fontSize: 13,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
함수 prop이 호출되면 여기에 기록됩니다
|
|
94
|
+
</div>
|
|
95
|
+
) : (
|
|
96
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontFamily: 'monospace', fontSize: 12 }}>
|
|
97
|
+
{logs.map((log) => (
|
|
98
|
+
<li
|
|
99
|
+
key={log.id}
|
|
100
|
+
style={{
|
|
101
|
+
display: 'flex',
|
|
102
|
+
alignItems: 'baseline',
|
|
103
|
+
gap: 10,
|
|
104
|
+
padding: '6px 20px',
|
|
105
|
+
borderBottom: '1px solid #f3f4f6',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<span style={{ color: '#9ca3af', fontSize: 11, minWidth: 92 }}>
|
|
109
|
+
{formatTime(log.timestamp)}
|
|
110
|
+
</span>
|
|
111
|
+
<span style={{ color: '#7c3aed', fontWeight: 600 }}>{log.name}</span>
|
|
112
|
+
<span style={{ color: '#374151', wordBreak: 'break-all', flex: 1 }}>
|
|
113
|
+
({formatArgs(log.args)})
|
|
114
|
+
</span>
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ReactElement, ChangeEvent, CSSProperties } from 'react'
|
|
2
|
+
import type { ArgType } from '@jogak/core'
|
|
3
|
+
|
|
4
|
+
export interface ControlsProps {
|
|
5
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
6
|
+
readonly argTypes: Readonly<Record<string, ArgType>>
|
|
7
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ControlKind = 'boolean' | 'number' | 'text' | 'select' | 'action' | 'json'
|
|
11
|
+
|
|
12
|
+
function resolveControlKind(value: unknown, argType: ArgType | undefined): ControlKind {
|
|
13
|
+
const ctrl = argType?.control
|
|
14
|
+
const isAction = argType?.action !== undefined && argType.action !== false
|
|
15
|
+
const isFunctionType = argType?.type === 'function' || typeof value === 'function'
|
|
16
|
+
|
|
17
|
+
if (isAction || isFunctionType) return 'action'
|
|
18
|
+
if (ctrl === 'boolean' || typeof value === 'boolean') return 'boolean'
|
|
19
|
+
if (ctrl === 'number' || ctrl === 'range' || typeof value === 'number') return 'number'
|
|
20
|
+
if (
|
|
21
|
+
ctrl === 'select' ||
|
|
22
|
+
ctrl === 'radio' ||
|
|
23
|
+
(argType?.options !== undefined && argType.options.length > 0)
|
|
24
|
+
)
|
|
25
|
+
return 'select'
|
|
26
|
+
if (ctrl === 'text' || ctrl === 'color' || typeof value === 'string') return 'text'
|
|
27
|
+
return 'json'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ControlInputProps {
|
|
31
|
+
readonly argKey: string
|
|
32
|
+
readonly value: unknown
|
|
33
|
+
readonly argType: ArgType | undefined
|
|
34
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ControlInput({ argKey, value, argType, onArgChange }: ControlInputProps): ReactElement {
|
|
38
|
+
const kind = resolveControlKind(value, argType)
|
|
39
|
+
|
|
40
|
+
switch (kind) {
|
|
41
|
+
case 'boolean':
|
|
42
|
+
return (
|
|
43
|
+
<input
|
|
44
|
+
type="checkbox"
|
|
45
|
+
checked={value === true}
|
|
46
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
47
|
+
onArgChange(argKey, e.target.checked)
|
|
48
|
+
}}
|
|
49
|
+
style={{ cursor: 'pointer', width: 16, height: 16, accentColor: '#2563eb' }}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
case 'number':
|
|
53
|
+
return (
|
|
54
|
+
<input
|
|
55
|
+
type="number"
|
|
56
|
+
value={typeof value === 'number' ? value : ''}
|
|
57
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
58
|
+
onArgChange(argKey, e.target.valueAsNumber)
|
|
59
|
+
}}
|
|
60
|
+
style={inputStyle}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
case 'select': {
|
|
64
|
+
const options = argType?.options ?? []
|
|
65
|
+
return (
|
|
66
|
+
<select
|
|
67
|
+
value={String(value ?? '')}
|
|
68
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
69
|
+
onArgChange(argKey, e.target.value)
|
|
70
|
+
}}
|
|
71
|
+
style={inputStyle}
|
|
72
|
+
>
|
|
73
|
+
{options.map((opt) => (
|
|
74
|
+
<option key={String(opt)} value={String(opt)}>
|
|
75
|
+
{String(opt)}
|
|
76
|
+
</option>
|
|
77
|
+
))}
|
|
78
|
+
</select>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
case 'text':
|
|
82
|
+
return (
|
|
83
|
+
<input
|
|
84
|
+
type="text"
|
|
85
|
+
value={typeof value === 'string' ? value : String(value ?? '')}
|
|
86
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
87
|
+
onArgChange(argKey, e.target.value)
|
|
88
|
+
}}
|
|
89
|
+
style={inputStyle}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
case 'action':
|
|
93
|
+
return (
|
|
94
|
+
<span
|
|
95
|
+
style={{
|
|
96
|
+
display: 'inline-block',
|
|
97
|
+
padding: '2px 8px',
|
|
98
|
+
fontSize: 11,
|
|
99
|
+
fontWeight: 600,
|
|
100
|
+
color: '#7c3aed',
|
|
101
|
+
background: '#f5f3ff',
|
|
102
|
+
border: '1px solid #ddd6fe',
|
|
103
|
+
borderRadius: 4,
|
|
104
|
+
fontFamily: 'monospace',
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
(action)
|
|
108
|
+
</span>
|
|
109
|
+
)
|
|
110
|
+
case 'json':
|
|
111
|
+
return (
|
|
112
|
+
<code style={{ fontSize: 12, color: '#6b7280', fontFamily: 'monospace' }}>
|
|
113
|
+
{JSON.stringify(value)}
|
|
114
|
+
</code>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const inputStyle: CSSProperties = {
|
|
120
|
+
padding: '4px 8px',
|
|
121
|
+
border: '1px solid #d1d5db',
|
|
122
|
+
borderRadius: 4,
|
|
123
|
+
fontSize: 13,
|
|
124
|
+
width: '100%',
|
|
125
|
+
maxWidth: 280,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const thStyle: CSSProperties = {
|
|
129
|
+
padding: '6px 20px',
|
|
130
|
+
textAlign: 'left',
|
|
131
|
+
color: '#6b7280',
|
|
132
|
+
fontWeight: 500,
|
|
133
|
+
fontSize: 12,
|
|
134
|
+
borderBottom: '1px solid #e5e7eb',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const tdStyle: CSSProperties = {
|
|
138
|
+
padding: '8px 20px',
|
|
139
|
+
verticalAlign: 'middle',
|
|
140
|
+
borderBottom: '1px solid #f3f4f6',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactElement {
|
|
144
|
+
const keys = Array.from(new Set([...Object.keys(args), ...Object.keys(argTypes)]))
|
|
145
|
+
const entries = keys.map((k) => [k, args[k]] as const)
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div style={{ borderTop: '2px solid #e5e7eb' }}>
|
|
149
|
+
<div
|
|
150
|
+
style={{
|
|
151
|
+
padding: '6px 20px',
|
|
152
|
+
fontSize: 11,
|
|
153
|
+
fontWeight: 700,
|
|
154
|
+
color: '#9ca3af',
|
|
155
|
+
textTransform: 'uppercase',
|
|
156
|
+
letterSpacing: '0.08em',
|
|
157
|
+
borderBottom: '1px solid #e5e7eb',
|
|
158
|
+
background: '#f9fafb',
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
Controls
|
|
162
|
+
</div>
|
|
163
|
+
{entries.length === 0 ? (
|
|
164
|
+
<div style={{ padding: '12px 20px', color: '#9ca3af', fontSize: 13 }}>
|
|
165
|
+
No args defined
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
169
|
+
<thead>
|
|
170
|
+
<tr>
|
|
171
|
+
<th style={thStyle}>Name</th>
|
|
172
|
+
<th style={thStyle}>Control</th>
|
|
173
|
+
<th style={thStyle}>Description</th>
|
|
174
|
+
</tr>
|
|
175
|
+
</thead>
|
|
176
|
+
<tbody>
|
|
177
|
+
{entries.map(([key, value]) => {
|
|
178
|
+
const argType = argTypes[key]
|
|
179
|
+
return (
|
|
180
|
+
<tr key={key}>
|
|
181
|
+
<td
|
|
182
|
+
style={{
|
|
183
|
+
...tdStyle,
|
|
184
|
+
fontFamily: 'monospace',
|
|
185
|
+
fontSize: 12,
|
|
186
|
+
color: '#374151',
|
|
187
|
+
whiteSpace: 'nowrap',
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
{key}
|
|
191
|
+
</td>
|
|
192
|
+
<td style={tdStyle}>
|
|
193
|
+
<ControlInput
|
|
194
|
+
argKey={key}
|
|
195
|
+
value={value}
|
|
196
|
+
argType={argType}
|
|
197
|
+
onArgChange={onArgChange}
|
|
198
|
+
/>
|
|
199
|
+
</td>
|
|
200
|
+
<td style={{ ...tdStyle, color: '#9ca3af' }}>
|
|
201
|
+
{argType?.description ?? ''}
|
|
202
|
+
</td>
|
|
203
|
+
</tr>
|
|
204
|
+
)
|
|
205
|
+
})}
|
|
206
|
+
</tbody>
|
|
207
|
+
</table>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|