@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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import type { CategoryMetaTree, RegistryEntryMeta } from '@jogak/core'
|
|
4
|
+
import { useRegistryMeta } from '@jogak/react'
|
|
5
|
+
|
|
6
|
+
export interface SidebarProps {
|
|
7
|
+
readonly selectedEntryId: string | null
|
|
8
|
+
readonly selectedJogakName: string | null
|
|
9
|
+
readonly onSelect: (entryId: string, jogakName: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sidebar — `useRegistryMeta`로 전환되어 lazy 모드에서도 모든 entry의 메타가 즉시 보인다.
|
|
14
|
+
* 계약 §10: hydrated 여부를 표시하지 않는다 — 사용자에겐 모든 entry가 동일하게 보임.
|
|
15
|
+
*/
|
|
16
|
+
export function Sidebar({
|
|
17
|
+
selectedEntryId,
|
|
18
|
+
selectedJogakName,
|
|
19
|
+
onSelect,
|
|
20
|
+
}: SidebarProps): ReactElement {
|
|
21
|
+
const [query, setQuery] = useState('')
|
|
22
|
+
const { metaTree, searchMeta } = useRegistryMeta()
|
|
23
|
+
|
|
24
|
+
const filtered = query.trim().length > 0 ? searchMeta(query) : null
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<aside
|
|
28
|
+
data-testid="sidebar"
|
|
29
|
+
style={{
|
|
30
|
+
borderRight: '1px solid #e5e7eb',
|
|
31
|
+
height: '100%',
|
|
32
|
+
overflow: 'auto',
|
|
33
|
+
display: 'flex',
|
|
34
|
+
flexDirection: 'column',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<div style={{ padding: '12px', borderBottom: '1px solid #e5e7eb' }}>
|
|
38
|
+
<input
|
|
39
|
+
type="search"
|
|
40
|
+
placeholder="Search components..."
|
|
41
|
+
value={query}
|
|
42
|
+
onChange={(e) => {
|
|
43
|
+
setQuery(e.target.value)
|
|
44
|
+
}}
|
|
45
|
+
style={{
|
|
46
|
+
width: '100%',
|
|
47
|
+
padding: '6px 8px',
|
|
48
|
+
border: '1px solid #d1d5db',
|
|
49
|
+
borderRadius: 4,
|
|
50
|
+
}}
|
|
51
|
+
aria-label="Search components"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
<nav style={{ flex: 1, overflow: 'auto', padding: '8px 0' }}>
|
|
55
|
+
{filtered !== null ? (
|
|
56
|
+
<FlatList
|
|
57
|
+
metas={filtered}
|
|
58
|
+
selectedEntryId={selectedEntryId}
|
|
59
|
+
selectedJogakName={selectedJogakName}
|
|
60
|
+
onSelect={onSelect}
|
|
61
|
+
/>
|
|
62
|
+
) : (
|
|
63
|
+
<TreeView
|
|
64
|
+
node={metaTree}
|
|
65
|
+
selectedEntryId={selectedEntryId}
|
|
66
|
+
selectedJogakName={selectedJogakName}
|
|
67
|
+
onSelect={onSelect}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
</nav>
|
|
71
|
+
</aside>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface FlatListProps {
|
|
76
|
+
readonly metas: readonly RegistryEntryMeta[]
|
|
77
|
+
readonly selectedEntryId: string | null
|
|
78
|
+
readonly selectedJogakName: string | null
|
|
79
|
+
readonly onSelect: (entryId: string, jogakName: string) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function FlatList({
|
|
83
|
+
metas,
|
|
84
|
+
selectedEntryId,
|
|
85
|
+
selectedJogakName,
|
|
86
|
+
onSelect,
|
|
87
|
+
}: FlatListProps): ReactElement {
|
|
88
|
+
if (metas.length === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<p style={{ padding: '0 12px', color: '#9ca3af', fontSize: 13 }}>
|
|
91
|
+
No results
|
|
92
|
+
</p>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
97
|
+
{metas.map((meta) => (
|
|
98
|
+
<li key={meta.id}>
|
|
99
|
+
<EntryGroup
|
|
100
|
+
meta={meta}
|
|
101
|
+
selectedEntryId={selectedEntryId}
|
|
102
|
+
selectedJogakName={selectedJogakName}
|
|
103
|
+
onSelect={onSelect}
|
|
104
|
+
indent={0}
|
|
105
|
+
/>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface TreeViewProps {
|
|
113
|
+
readonly node: CategoryMetaTree
|
|
114
|
+
readonly selectedEntryId: string | null
|
|
115
|
+
readonly selectedJogakName: string | null
|
|
116
|
+
readonly onSelect: (entryId: string, jogakName: string) => void
|
|
117
|
+
readonly depth?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function TreeView({
|
|
121
|
+
node,
|
|
122
|
+
selectedEntryId,
|
|
123
|
+
selectedJogakName,
|
|
124
|
+
onSelect,
|
|
125
|
+
depth = 0,
|
|
126
|
+
}: TreeViewProps): ReactElement {
|
|
127
|
+
return (
|
|
128
|
+
<ul
|
|
129
|
+
style={{
|
|
130
|
+
listStyle: 'none',
|
|
131
|
+
margin: 0,
|
|
132
|
+
padding: `0 0 0 ${depth * 12}px`,
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{Object.entries(node).map(([key, child]) => (
|
|
136
|
+
<li key={key}>
|
|
137
|
+
{'id' in child ? (
|
|
138
|
+
<EntryGroup
|
|
139
|
+
meta={child as RegistryEntryMeta}
|
|
140
|
+
selectedEntryId={selectedEntryId}
|
|
141
|
+
selectedJogakName={selectedJogakName}
|
|
142
|
+
onSelect={onSelect}
|
|
143
|
+
indent={0}
|
|
144
|
+
/>
|
|
145
|
+
) : (
|
|
146
|
+
<CategoryGroup
|
|
147
|
+
label={key}
|
|
148
|
+
node={child as CategoryMetaTree}
|
|
149
|
+
selectedEntryId={selectedEntryId}
|
|
150
|
+
selectedJogakName={selectedJogakName}
|
|
151
|
+
onSelect={onSelect}
|
|
152
|
+
depth={depth + 1}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
</li>
|
|
156
|
+
))}
|
|
157
|
+
</ul>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface CategoryGroupProps {
|
|
162
|
+
readonly label: string
|
|
163
|
+
readonly node: CategoryMetaTree
|
|
164
|
+
readonly selectedEntryId: string | null
|
|
165
|
+
readonly selectedJogakName: string | null
|
|
166
|
+
readonly onSelect: (entryId: string, jogakName: string) => void
|
|
167
|
+
readonly depth: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function CategoryGroup({
|
|
171
|
+
label,
|
|
172
|
+
node,
|
|
173
|
+
selectedEntryId,
|
|
174
|
+
selectedJogakName,
|
|
175
|
+
onSelect,
|
|
176
|
+
depth,
|
|
177
|
+
}: CategoryGroupProps): ReactElement {
|
|
178
|
+
const [open, setOpen] = useState(true)
|
|
179
|
+
return (
|
|
180
|
+
<div>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={() => {
|
|
184
|
+
setOpen((v) => !v)
|
|
185
|
+
}}
|
|
186
|
+
style={{
|
|
187
|
+
display: 'flex',
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
gap: 4,
|
|
190
|
+
width: '100%',
|
|
191
|
+
padding: '4px 12px',
|
|
192
|
+
background: 'none',
|
|
193
|
+
border: 'none',
|
|
194
|
+
cursor: 'pointer',
|
|
195
|
+
fontSize: 12,
|
|
196
|
+
fontWeight: 600,
|
|
197
|
+
color: '#6b7280',
|
|
198
|
+
textTransform: 'uppercase',
|
|
199
|
+
letterSpacing: '0.05em',
|
|
200
|
+
}}
|
|
201
|
+
aria-expanded={open}
|
|
202
|
+
>
|
|
203
|
+
<span>{open ? '▾' : '▸'}</span>
|
|
204
|
+
{label}
|
|
205
|
+
</button>
|
|
206
|
+
{open && (
|
|
207
|
+
<TreeView
|
|
208
|
+
node={node}
|
|
209
|
+
selectedEntryId={selectedEntryId}
|
|
210
|
+
selectedJogakName={selectedJogakName}
|
|
211
|
+
onSelect={onSelect}
|
|
212
|
+
depth={depth}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface EntryGroupProps {
|
|
220
|
+
readonly meta: RegistryEntryMeta
|
|
221
|
+
readonly selectedEntryId: string | null
|
|
222
|
+
readonly selectedJogakName: string | null
|
|
223
|
+
readonly onSelect: (entryId: string, jogakName: string) => void
|
|
224
|
+
readonly indent: number
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function EntryGroup({
|
|
228
|
+
meta,
|
|
229
|
+
selectedEntryId,
|
|
230
|
+
selectedJogakName,
|
|
231
|
+
onSelect,
|
|
232
|
+
indent,
|
|
233
|
+
}: EntryGroupProps): ReactElement {
|
|
234
|
+
const isCurrentEntry = meta.id === selectedEntryId
|
|
235
|
+
const [open, setOpen] = useState(isCurrentEntry)
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (isCurrentEntry) setOpen(true)
|
|
239
|
+
}, [isCurrentEntry])
|
|
240
|
+
|
|
241
|
+
const label = meta.title.split('/').pop() ?? meta.title
|
|
242
|
+
const paddingLeft = 16 + indent * 12
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={() => {
|
|
249
|
+
if (!isCurrentEntry) {
|
|
250
|
+
setOpen(true)
|
|
251
|
+
const first = meta.jogakNames[0]
|
|
252
|
+
if (first !== undefined) onSelect(meta.id, first)
|
|
253
|
+
} else {
|
|
254
|
+
setOpen((v) => !v)
|
|
255
|
+
}
|
|
256
|
+
}}
|
|
257
|
+
style={{
|
|
258
|
+
display: 'flex',
|
|
259
|
+
alignItems: 'center',
|
|
260
|
+
gap: 6,
|
|
261
|
+
width: '100%',
|
|
262
|
+
padding: `5px 12px 5px ${paddingLeft}px`,
|
|
263
|
+
background: isCurrentEntry ? '#eff6ff' : 'none',
|
|
264
|
+
border: 'none',
|
|
265
|
+
cursor: 'pointer',
|
|
266
|
+
fontSize: 13,
|
|
267
|
+
color: isCurrentEntry ? '#2563eb' : '#374151',
|
|
268
|
+
fontWeight: isCurrentEntry ? 500 : 400,
|
|
269
|
+
textAlign: 'left',
|
|
270
|
+
}}
|
|
271
|
+
aria-expanded={open}
|
|
272
|
+
>
|
|
273
|
+
<span style={{ fontSize: 10, flexShrink: 0, lineHeight: 1 }}>
|
|
274
|
+
{open ? '▾' : '▸'}
|
|
275
|
+
</span>
|
|
276
|
+
{label}
|
|
277
|
+
</button>
|
|
278
|
+
{open && (
|
|
279
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
280
|
+
{meta.jogakNames.map((jogakName) => {
|
|
281
|
+
const isSelected = isCurrentEntry && jogakName === selectedJogakName
|
|
282
|
+
return (
|
|
283
|
+
<li key={jogakName}>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => {
|
|
287
|
+
onSelect(meta.id, jogakName)
|
|
288
|
+
}}
|
|
289
|
+
style={{
|
|
290
|
+
display: 'block',
|
|
291
|
+
width: '100%',
|
|
292
|
+
textAlign: 'left',
|
|
293
|
+
padding: `4px 12px 4px ${paddingLeft + 18}px`,
|
|
294
|
+
background: isSelected ? '#dbeafe' : 'none',
|
|
295
|
+
border: 'none',
|
|
296
|
+
cursor: 'pointer',
|
|
297
|
+
fontSize: 12,
|
|
298
|
+
color: isSelected ? '#1d4ed8' : '#6b7280',
|
|
299
|
+
fontWeight: isSelected ? 500 : 400,
|
|
300
|
+
}}
|
|
301
|
+
aria-current={isSelected ? 'true' : undefined}
|
|
302
|
+
>
|
|
303
|
+
{jogakName}
|
|
304
|
+
</button>
|
|
305
|
+
</li>
|
|
306
|
+
)
|
|
307
|
+
})}
|
|
308
|
+
</ul>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useRegistry as useRegistryFromAdapter } from '@jogak/react'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import type { CategoryTree, RegistryEntry } from '@jogak/core'
|
|
4
|
+
|
|
5
|
+
export interface UseRegistryReturn {
|
|
6
|
+
readonly entries: readonly RegistryEntry[]
|
|
7
|
+
readonly tree: CategoryTree
|
|
8
|
+
readonly search: (query: string) => readonly RegistryEntry[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useRegistry(): UseRegistryReturn {
|
|
12
|
+
const registry = useRegistryFromAdapter()
|
|
13
|
+
|
|
14
|
+
const entries = useMemo(() => registry.getAll(), [registry])
|
|
15
|
+
const tree = useMemo(() => registry.getTree(), [registry])
|
|
16
|
+
const search = useMemo(
|
|
17
|
+
() => (query: string) => registry.search(query),
|
|
18
|
+
[registry],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return { entries, tree, search }
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { JogakApp } from './app/App.js'
|
|
2
|
+
export { Sidebar } from './components/Sidebar/index.js'
|
|
3
|
+
export { Preview } from './components/Preview/index.js'
|
|
4
|
+
export { Controls } from './components/Controls/index.js'
|
|
5
|
+
export { Actions } from './components/Actions/index.js'
|
|
6
|
+
export { useRegistry } from './hooks/useRegistry.js'
|
|
7
|
+
|
|
8
|
+
export type { JogakAppProps } from './app/App.js'
|
|
9
|
+
export type { SidebarProps } from './components/Sidebar/index.js'
|
|
10
|
+
export type { PreviewProps } from './components/Preview/index.js'
|
|
11
|
+
export type { ControlsProps } from './components/Controls/index.js'
|
|
12
|
+
export type { UseRegistryReturn } from './hooks/useRegistry.js'
|