@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.
- package/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- 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
|
+
}
|