@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jogak/ui",
3
- "version": "0.1.0-alpha.0",
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.js",
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.js"
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.js"
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
- "@jogak/core": "0.1.0-alpha.0",
59
- "@jogak/react": "0.1.0-alpha.0"
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>
@@ -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
+ }
@@ -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
+ }