@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.
@@ -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'
@@ -0,0 +1,6 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module 'virtual:jogak' {
4
+ /** 플러그인 설정에서 지정한 prism-react-renderer 테마 이름 */
5
+ export const _jogakCodeTheme: string
6
+ }