@mdxui/terminal 2.0.0

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.
Files changed (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * @mdxui/terminal Interactive Decorator
3
+ *
4
+ * Storybook decorator and utilities for testing interactive terminal components.
5
+ * Provides keyboard event capture and visual feedback for navigation testing.
6
+ *
7
+ * @packageDocumentation
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * // In your story file
12
+ * import type { Meta, StoryObj } from '@storybook/react'
13
+ * import { InteractiveDecorator, useKeyboardNavigation } from '@mdxui/terminal/storybook'
14
+ * import { Select } from './Select'
15
+ *
16
+ * const meta: Meta<typeof Select> = {
17
+ * title: 'Terminal/Select',
18
+ * component: Select,
19
+ * decorators: [InteractiveDecorator],
20
+ * parameters: {
21
+ * interactive: {
22
+ * vimBindings: true,
23
+ * showKeyLog: true,
24
+ * },
25
+ * },
26
+ * }
27
+ * ```
28
+ */
29
+
30
+ import React, { useEffect, useState, useCallback, useRef } from 'react'
31
+ import type { Decorator } from '@storybook/react'
32
+ import { createInteractiveRenderer } from '../renderers/interactive'
33
+ import type { InteractiveRenderer, InteractiveRendererConfig } from '../renderers/interactive'
34
+ import {
35
+ createKeyboardSimulator,
36
+ createDOMKeyHandler,
37
+ type KeyboardSimulator,
38
+ type KeyPress,
39
+ } from './keyboard-simulator'
40
+
41
+ // ============================================================================
42
+ // Types
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Parameters for the interactive decorator
47
+ */
48
+ export interface InteractiveDecoratorParameters {
49
+ /** Interactive renderer configuration */
50
+ interactive?: InteractiveRendererConfig & {
51
+ /** Show key press log panel */
52
+ showKeyLog?: boolean
53
+ /** Maximum key log entries to display */
54
+ maxKeyLogEntries?: number
55
+ /** Show focus indicator overlay */
56
+ showFocusOverlay?: boolean
57
+ /** Keys to ignore from DOM forwarding */
58
+ ignoreKeys?: string[]
59
+ /** Auto-focus the interactive container */
60
+ autoFocus?: boolean
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Interactive context for stories
66
+ */
67
+ export interface InteractiveContext {
68
+ /** The interactive renderer instance */
69
+ renderer: InteractiveRenderer | null
70
+ /** The keyboard simulator instance */
71
+ simulator: KeyboardSimulator | null
72
+ /** List of pressed keys */
73
+ keyLog: string[]
74
+ /** Currently focused element ID */
75
+ focusedId: string | null
76
+ /** Clear the key log */
77
+ clearKeyLog: () => void
78
+ }
79
+
80
+ // ============================================================================
81
+ // React Context
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Context for accessing interactive state in stories
86
+ */
87
+ export const InteractiveContext = React.createContext<InteractiveContext>({
88
+ renderer: null,
89
+ simulator: null,
90
+ keyLog: [],
91
+ focusedId: null,
92
+ clearKeyLog: () => {},
93
+ })
94
+
95
+ /**
96
+ * Hook to access interactive context in story components
97
+ */
98
+ export function useInteractiveContext(): InteractiveContext {
99
+ return React.useContext(InteractiveContext)
100
+ }
101
+
102
+ // ============================================================================
103
+ // Key Log Panel Component
104
+ // ============================================================================
105
+
106
+ interface KeyLogPanelProps {
107
+ keyLog: string[]
108
+ focusedId: string | null
109
+ onClear: () => void
110
+ }
111
+
112
+ /**
113
+ * Visual panel showing key press history and focus state
114
+ */
115
+ function KeyLogPanel({ keyLog, focusedId, onClear }: KeyLogPanelProps): React.ReactElement {
116
+ return (
117
+ <div
118
+ style={{
119
+ position: 'fixed',
120
+ bottom: '16px',
121
+ right: '16px',
122
+ width: '240px',
123
+ maxHeight: '200px',
124
+ backgroundColor: 'rgba(0, 0, 0, 0.85)',
125
+ color: '#e5e5e5',
126
+ borderRadius: '8px',
127
+ padding: '12px',
128
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
129
+ fontSize: '12px',
130
+ zIndex: 9999,
131
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
132
+ }}
133
+ >
134
+ <div
135
+ style={{
136
+ display: 'flex',
137
+ justifyContent: 'space-between',
138
+ alignItems: 'center',
139
+ marginBottom: '8px',
140
+ paddingBottom: '8px',
141
+ borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
142
+ }}
143
+ >
144
+ <span style={{ fontWeight: 600, color: '#a3e635' }}>Keyboard Log</span>
145
+ <button
146
+ onClick={onClear}
147
+ style={{
148
+ background: 'transparent',
149
+ border: 'none',
150
+ color: '#737373',
151
+ cursor: 'pointer',
152
+ fontSize: '11px',
153
+ padding: '2px 6px',
154
+ }}
155
+ >
156
+ Clear
157
+ </button>
158
+ </div>
159
+
160
+ {focusedId && (
161
+ <div
162
+ style={{
163
+ marginBottom: '8px',
164
+ padding: '4px 8px',
165
+ backgroundColor: 'rgba(163, 230, 53, 0.15)',
166
+ borderRadius: '4px',
167
+ color: '#a3e635',
168
+ }}
169
+ >
170
+ Focus: <code style={{ color: '#fde047' }}>{focusedId}</code>
171
+ </div>
172
+ )}
173
+
174
+ <div
175
+ style={{
176
+ maxHeight: '120px',
177
+ overflowY: 'auto',
178
+ }}
179
+ >
180
+ {keyLog.length === 0 ? (
181
+ <div style={{ color: '#737373', fontStyle: 'italic' }}>
182
+ Press keys to see log...
183
+ </div>
184
+ ) : (
185
+ keyLog.map((key, index) => (
186
+ <div
187
+ key={`${key}-${index}`}
188
+ style={{
189
+ padding: '2px 0',
190
+ display: 'flex',
191
+ alignItems: 'center',
192
+ gap: '8px',
193
+ }}
194
+ >
195
+ <span style={{ color: '#737373' }}>{index + 1}.</span>
196
+ <code
197
+ style={{
198
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
199
+ padding: '1px 6px',
200
+ borderRadius: '3px',
201
+ color: '#60a5fa',
202
+ }}
203
+ >
204
+ {key}
205
+ </code>
206
+ </div>
207
+ ))
208
+ )}
209
+ </div>
210
+
211
+ <div
212
+ style={{
213
+ marginTop: '8px',
214
+ paddingTop: '8px',
215
+ borderTop: '1px solid rgba(255, 255, 255, 0.1)',
216
+ color: '#737373',
217
+ fontSize: '10px',
218
+ }}
219
+ >
220
+ Click container to focus. Use Tab, arrows, vim keys.
221
+ </div>
222
+ </div>
223
+ )
224
+ }
225
+
226
+ // ============================================================================
227
+ // Interactive Container Component
228
+ // ============================================================================
229
+
230
+ interface InteractiveContainerProps {
231
+ children: React.ReactNode
232
+ config: InteractiveDecoratorParameters['interactive']
233
+ }
234
+
235
+ /**
236
+ * Container component that captures keyboard events and provides interactive context
237
+ */
238
+ function InteractiveContainer({
239
+ children,
240
+ config = {},
241
+ }: InteractiveContainerProps): React.ReactElement {
242
+ const containerRef = useRef<HTMLDivElement>(null)
243
+ const [renderer, setRenderer] = useState<InteractiveRenderer | null>(null)
244
+ const [simulator, setSimulator] = useState<KeyboardSimulator | null>(null)
245
+ const [keyLog, setKeyLog] = useState<string[]>([])
246
+ const [focusedId, setFocusedId] = useState<string | null>(null)
247
+
248
+ const {
249
+ showKeyLog = true,
250
+ maxKeyLogEntries = 20,
251
+ showFocusOverlay = false,
252
+ ignoreKeys = ['f5', 'f12'],
253
+ autoFocus = true,
254
+ ...rendererConfig
255
+ } = config
256
+
257
+ // Initialize renderer and simulator
258
+ useEffect(() => {
259
+ let mounted = true
260
+
261
+ async function init() {
262
+ const newRenderer = await createInteractiveRenderer(rendererConfig)
263
+
264
+ if (!mounted) {
265
+ newRenderer.destroy()
266
+ return
267
+ }
268
+
269
+ const newSimulator = createKeyboardSimulator({
270
+ renderer: newRenderer,
271
+ onKeyPress: (key, modifiers) => {
272
+ const parts: string[] = []
273
+ if (modifiers.ctrl) parts.push('Ctrl')
274
+ if (modifiers.alt) parts.push('Alt')
275
+ if (modifiers.shift) parts.push('Shift')
276
+ if (modifiers.meta) parts.push('Meta')
277
+ parts.push(key)
278
+
279
+ setKeyLog((prev) => {
280
+ const newLog = [...prev, parts.join('+')]
281
+ return newLog.slice(-maxKeyLogEntries)
282
+ })
283
+ },
284
+ })
285
+
286
+ setRenderer(newRenderer)
287
+ setSimulator(newSimulator)
288
+ }
289
+
290
+ init()
291
+
292
+ return () => {
293
+ mounted = false
294
+ renderer?.destroy()
295
+ }
296
+ }, [])
297
+
298
+ // Track focus changes
299
+ useEffect(() => {
300
+ if (!renderer) return
301
+
302
+ const interval = setInterval(() => {
303
+ const currentFocusedId = renderer.getFocusedId()
304
+ if (currentFocusedId !== focusedId) {
305
+ setFocusedId(currentFocusedId)
306
+ }
307
+ }, 100)
308
+
309
+ return () => clearInterval(interval)
310
+ }, [renderer, focusedId])
311
+
312
+ // Handle DOM keyboard events
313
+ const handleKeyDown = useCallback(
314
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
315
+ if (!simulator) return
316
+
317
+ // Create and use DOM handler
318
+ const handler = createDOMKeyHandler(simulator, {
319
+ preventDefault: true,
320
+ stopPropagation: true,
321
+ ignoreKeys,
322
+ })
323
+
324
+ handler(event.nativeEvent)
325
+ },
326
+ [simulator, ignoreKeys]
327
+ )
328
+
329
+ // Handle container focus
330
+ const handleContainerFocus = useCallback(() => {
331
+ // Auto-focus first focusable element if nothing is focused
332
+ if (renderer && !renderer.getFocusedId()) {
333
+ renderer.focusNext()
334
+ }
335
+ }, [renderer])
336
+
337
+ // Auto-focus container on mount
338
+ useEffect(() => {
339
+ if (autoFocus && containerRef.current) {
340
+ containerRef.current.focus()
341
+ }
342
+ }, [autoFocus, renderer])
343
+
344
+ const clearKeyLog = useCallback(() => {
345
+ setKeyLog([])
346
+ simulator?.clearHistory()
347
+ }, [simulator])
348
+
349
+ const contextValue: InteractiveContext = {
350
+ renderer,
351
+ simulator,
352
+ keyLog,
353
+ focusedId,
354
+ clearKeyLog,
355
+ }
356
+
357
+ return (
358
+ <InteractiveContext.Provider value={contextValue}>
359
+ <div
360
+ ref={containerRef}
361
+ tabIndex={0}
362
+ onKeyDown={handleKeyDown}
363
+ onFocus={handleContainerFocus}
364
+ style={{
365
+ outline: 'none',
366
+ minHeight: '100%',
367
+ position: 'relative',
368
+ }}
369
+ data-interactive-container="true"
370
+ >
371
+ {children}
372
+
373
+ {showKeyLog && (
374
+ <KeyLogPanel
375
+ keyLog={keyLog}
376
+ focusedId={focusedId}
377
+ onClear={clearKeyLog}
378
+ />
379
+ )}
380
+
381
+ {showFocusOverlay && focusedId && (
382
+ <div
383
+ style={{
384
+ position: 'fixed',
385
+ top: '16px',
386
+ left: '16px',
387
+ backgroundColor: 'rgba(163, 230, 53, 0.9)',
388
+ color: '#000',
389
+ padding: '4px 8px',
390
+ borderRadius: '4px',
391
+ fontFamily: 'ui-monospace, monospace',
392
+ fontSize: '11px',
393
+ fontWeight: 600,
394
+ zIndex: 9998,
395
+ }}
396
+ >
397
+ Focused: {focusedId}
398
+ </div>
399
+ )}
400
+ </div>
401
+ </InteractiveContext.Provider>
402
+ )
403
+ }
404
+
405
+ // ============================================================================
406
+ // Storybook Decorator
407
+ // ============================================================================
408
+
409
+ /**
410
+ * Storybook decorator for interactive terminal component testing.
411
+ *
412
+ * This decorator:
413
+ * - Creates an InteractiveRenderer instance
414
+ * - Captures keyboard events from the DOM
415
+ * - Forwards them to the renderer
416
+ * - Optionally shows a key log panel for debugging
417
+ * - Provides context for accessing the renderer/simulator in stories
418
+ *
419
+ * @example
420
+ * ```tsx
421
+ * // meta configuration
422
+ * const meta: Meta<typeof MyComponent> = {
423
+ * title: 'Terminal/MyComponent',
424
+ * component: MyComponent,
425
+ * decorators: [InteractiveDecorator],
426
+ * parameters: {
427
+ * interactive: {
428
+ * vimBindings: true,
429
+ * showKeyLog: true,
430
+ * maxKeyLogEntries: 10,
431
+ * },
432
+ * },
433
+ * }
434
+ *
435
+ * // Access context in story
436
+ * export const Interactive: Story = {
437
+ * render: () => {
438
+ * const { renderer, simulator } = useInteractiveContext()
439
+ * // Use renderer/simulator in your component
440
+ * return <MyComponent renderer={renderer} />
441
+ * },
442
+ * }
443
+ * ```
444
+ */
445
+ export const InteractiveDecorator: Decorator = (Story, context) => {
446
+ const interactiveParams = context.parameters?.interactive as
447
+ | InteractiveDecoratorParameters['interactive']
448
+ | undefined
449
+
450
+ return (
451
+ <InteractiveContainer config={interactiveParams}>
452
+ <Story />
453
+ </InteractiveContainer>
454
+ )
455
+ }
456
+
457
+ // ============================================================================
458
+ // Hook for Manual Integration
459
+ // ============================================================================
460
+
461
+ /**
462
+ * Hook for stories that need manual keyboard navigation setup.
463
+ *
464
+ * Use this when you need more control over the keyboard handling
465
+ * or don't want to use the decorator.
466
+ *
467
+ * @example
468
+ * ```tsx
469
+ * function MyInteractiveStory() {
470
+ * const { renderer, simulator, keyLog, containerProps } = useKeyboardNavigation({
471
+ * vimBindings: true,
472
+ * })
473
+ *
474
+ * useEffect(() => {
475
+ * if (!renderer) return
476
+ *
477
+ * renderer.registerFocusable('item-1', { tabIndex: 0 })
478
+ * renderer.registerFocusable('item-2', { tabIndex: 0 })
479
+ * renderer.focusById('item-1')
480
+ * }, [renderer])
481
+ *
482
+ * return (
483
+ * <div {...containerProps}>
484
+ * <div data-focused={renderer?.getFocusedId() === 'item-1'}>Item 1</div>
485
+ * <div data-focused={renderer?.getFocusedId() === 'item-2'}>Item 2</div>
486
+ * </div>
487
+ * )
488
+ * }
489
+ * ```
490
+ */
491
+ export function useKeyboardNavigation(config?: InteractiveRendererConfig): {
492
+ renderer: InteractiveRenderer | null
493
+ simulator: KeyboardSimulator | null
494
+ keyLog: string[]
495
+ focusedId: string | null
496
+ clearKeyLog: () => void
497
+ containerProps: {
498
+ ref: React.RefObject<HTMLDivElement>
499
+ tabIndex: number
500
+ onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
501
+ onFocus: () => void
502
+ style: React.CSSProperties
503
+ }
504
+ } {
505
+ const containerRef = useRef<HTMLDivElement>(null)
506
+ const [renderer, setRenderer] = useState<InteractiveRenderer | null>(null)
507
+ const [simulator, setSimulator] = useState<KeyboardSimulator | null>(null)
508
+ const [keyLog, setKeyLog] = useState<string[]>([])
509
+ const [focusedId, setFocusedId] = useState<string | null>(null)
510
+
511
+ // Initialize
512
+ useEffect(() => {
513
+ let mounted = true
514
+
515
+ async function init() {
516
+ const newRenderer = await createInteractiveRenderer(config)
517
+
518
+ if (!mounted) {
519
+ newRenderer.destroy()
520
+ return
521
+ }
522
+
523
+ const newSimulator = createKeyboardSimulator({
524
+ renderer: newRenderer,
525
+ onKeyPress: (key, modifiers) => {
526
+ const parts: string[] = []
527
+ if (modifiers.ctrl) parts.push('Ctrl')
528
+ if (modifiers.alt) parts.push('Alt')
529
+ if (modifiers.shift) parts.push('Shift')
530
+ if (modifiers.meta) parts.push('Meta')
531
+ parts.push(key)
532
+
533
+ setKeyLog((prev) => [...prev.slice(-19), parts.join('+')])
534
+ },
535
+ })
536
+
537
+ setRenderer(newRenderer)
538
+ setSimulator(newSimulator)
539
+ }
540
+
541
+ init()
542
+
543
+ return () => {
544
+ mounted = false
545
+ renderer?.destroy()
546
+ }
547
+ }, [])
548
+
549
+ // Track focus
550
+ useEffect(() => {
551
+ if (!renderer) return
552
+
553
+ const interval = setInterval(() => {
554
+ setFocusedId(renderer.getFocusedId())
555
+ }, 100)
556
+
557
+ return () => clearInterval(interval)
558
+ }, [renderer])
559
+
560
+ const handleKeyDown = useCallback(
561
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
562
+ if (!simulator) return
563
+
564
+ const handler = createDOMKeyHandler(simulator, {
565
+ preventDefault: true,
566
+ stopPropagation: true,
567
+ })
568
+
569
+ handler(event.nativeEvent)
570
+ },
571
+ [simulator]
572
+ )
573
+
574
+ const handleFocus = useCallback(() => {
575
+ if (renderer && !renderer.getFocusedId()) {
576
+ renderer.focusNext()
577
+ }
578
+ }, [renderer])
579
+
580
+ const clearKeyLog = useCallback(() => {
581
+ setKeyLog([])
582
+ simulator?.clearHistory()
583
+ }, [simulator])
584
+
585
+ return {
586
+ renderer,
587
+ simulator,
588
+ keyLog,
589
+ focusedId,
590
+ clearKeyLog,
591
+ containerProps: {
592
+ ref: containerRef,
593
+ tabIndex: 0,
594
+ onKeyDown: handleKeyDown,
595
+ onFocus: handleFocus,
596
+ style: { outline: 'none' },
597
+ },
598
+ }
599
+ }
600
+
601
+ // ============================================================================
602
+ // Test Utilities for Stories
603
+ // ============================================================================
604
+
605
+ /**
606
+ * Programmatically simulate keyboard navigation in a story.
607
+ *
608
+ * Useful for creating animated demos or testing specific navigation flows.
609
+ *
610
+ * @example
611
+ * ```tsx
612
+ * export const AnimatedDemo: Story = {
613
+ * render: () => {
614
+ * const { renderer, simulator } = useInteractiveContext()
615
+ *
616
+ * useEffect(() => {
617
+ * if (!simulator) return
618
+ *
619
+ * // Simulate navigation sequence
620
+ * const cleanup = simulateKeyboardDemo(simulator, [
621
+ * { keys: [{ key: 'tab' }], delay: 500 },
622
+ * { keys: [{ key: 'tab' }], delay: 500 },
623
+ * { keys: [{ key: 'enter' }], delay: 500 },
624
+ * ])
625
+ *
626
+ * return cleanup
627
+ * }, [simulator])
628
+ *
629
+ * return <MyComponent />
630
+ * },
631
+ * }
632
+ * ```
633
+ */
634
+ export function simulateKeyboardDemo(
635
+ simulator: KeyboardSimulator,
636
+ steps: Array<{ keys: KeyPress[]; delay: number }>
637
+ ): () => void {
638
+ let cancelled = false
639
+ const timeouts: ReturnType<typeof setTimeout>[] = []
640
+
641
+ let cumulativeDelay = 0
642
+
643
+ for (const step of steps) {
644
+ cumulativeDelay += step.delay
645
+
646
+ const timeout = setTimeout(() => {
647
+ if (!cancelled) {
648
+ simulator.sequence(step.keys)
649
+ }
650
+ }, cumulativeDelay)
651
+
652
+ timeouts.push(timeout)
653
+ }
654
+
655
+ return () => {
656
+ cancelled = true
657
+ timeouts.forEach((t) => clearTimeout(t))
658
+ }
659
+ }