@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,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus Management System
|
|
3
|
+
*
|
|
4
|
+
* React hooks and context for managing focus state across terminal UI components.
|
|
5
|
+
* Provides focus tracking, navigation, and keyboard integration.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useState, useCallback, useRef, useEffect, useContext, createContext, type ReactNode, type ReactElement } from 'react'
|
|
11
|
+
import { createFocusId, type FocusId } from '../types'
|
|
12
|
+
import type { KeyModifiers } from './manager'
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// useKeyboard Hook
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for the useKeyboard hook.
|
|
20
|
+
*/
|
|
21
|
+
export interface UseKeyboardOptions {
|
|
22
|
+
/** Whether keyboard handling is enabled (default: true) */
|
|
23
|
+
enabled?: boolean
|
|
24
|
+
/** Priority for keyboard event handling (higher wins) */
|
|
25
|
+
priority?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return value from the useKeyboard hook.
|
|
30
|
+
*/
|
|
31
|
+
export interface UseKeyboardResult {
|
|
32
|
+
/** Whether keyboard handling is currently enabled */
|
|
33
|
+
enabled: boolean
|
|
34
|
+
/** Current priority level */
|
|
35
|
+
priority: number
|
|
36
|
+
/** Enable keyboard handling */
|
|
37
|
+
enable: () => void
|
|
38
|
+
/** Disable keyboard handling */
|
|
39
|
+
disable: () => void
|
|
40
|
+
/** Cleanup function (called on unmount) */
|
|
41
|
+
cleanup: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* React hook for handling keyboard input with modifier key support.
|
|
46
|
+
*
|
|
47
|
+
* Provides a handler that receives key presses along with modifier state.
|
|
48
|
+
* Useful for building custom keyboard interaction patterns.
|
|
49
|
+
*
|
|
50
|
+
* @param handler - Callback receiving key name and modifier state
|
|
51
|
+
* @param options - Configuration options
|
|
52
|
+
* @returns Controls for enabling/disabling keyboard handling
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```tsx
|
|
56
|
+
* function MyComponent() {
|
|
57
|
+
* const keyboard = useKeyboard((key, modifiers) => {
|
|
58
|
+
* if (modifiers.ctrl && key === 'c') {
|
|
59
|
+
* handleCopy()
|
|
60
|
+
* }
|
|
61
|
+
* })
|
|
62
|
+
*
|
|
63
|
+
* return <div>...</div>
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useKeyboard(
|
|
68
|
+
handler: (key: string, modifiers: KeyModifiers) => void,
|
|
69
|
+
options?: UseKeyboardOptions
|
|
70
|
+
): UseKeyboardResult {
|
|
71
|
+
const [enabled, setEnabled] = useState(options?.enabled !== false)
|
|
72
|
+
const priority = options?.priority ?? 0
|
|
73
|
+
const handlerRef = useRef(handler)
|
|
74
|
+
handlerRef.current = handler
|
|
75
|
+
|
|
76
|
+
const cleanup = useCallback(() => {
|
|
77
|
+
// Cleanup function for when hook unmounts
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
// Setup keyboard event listeners when in a real environment
|
|
82
|
+
// This is a placeholder for the actual implementation
|
|
83
|
+
return cleanup
|
|
84
|
+
}, [cleanup])
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
enabled,
|
|
88
|
+
priority,
|
|
89
|
+
enable: () => setEnabled(true),
|
|
90
|
+
disable: () => setEnabled(false),
|
|
91
|
+
cleanup,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// useFocus Hook
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Options for the useFocus hook.
|
|
101
|
+
*/
|
|
102
|
+
export interface UseFocusOptions {
|
|
103
|
+
/** Custom ID for the focusable element */
|
|
104
|
+
id?: string
|
|
105
|
+
/** Whether to focus on mount (default: false) */
|
|
106
|
+
autoFocus?: boolean
|
|
107
|
+
/** Tab order index */
|
|
108
|
+
tabIndex?: number
|
|
109
|
+
/** Callback when element receives focus */
|
|
110
|
+
onFocus?: () => void
|
|
111
|
+
/** Callback when element loses focus */
|
|
112
|
+
onBlur?: () => void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Interface for a focusable element returned by useFocus.
|
|
117
|
+
*/
|
|
118
|
+
export interface FocusableElement {
|
|
119
|
+
/** Unique ID for this focusable element */
|
|
120
|
+
id: FocusId
|
|
121
|
+
/** Whether this element currently has focus */
|
|
122
|
+
focused: boolean
|
|
123
|
+
/** Tab order index */
|
|
124
|
+
tabIndex: number
|
|
125
|
+
/** Programmatically focus this element */
|
|
126
|
+
focus: () => void
|
|
127
|
+
/** Programmatically blur this element */
|
|
128
|
+
blur: () => void
|
|
129
|
+
/** Move focus to the next focusable element */
|
|
130
|
+
focusNext: () => void
|
|
131
|
+
/** Move focus to the previous focusable element */
|
|
132
|
+
focusPrev: () => void
|
|
133
|
+
/** Move focus to the first focusable element */
|
|
134
|
+
focusFirst: () => void
|
|
135
|
+
/** Move focus to the last focusable element */
|
|
136
|
+
focusLast: () => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* React hook for focus management in terminal UIs.
|
|
141
|
+
*
|
|
142
|
+
* Provides focus state and navigation helpers for building
|
|
143
|
+
* accessible keyboard-navigable interfaces.
|
|
144
|
+
*
|
|
145
|
+
* @param options - Configuration options
|
|
146
|
+
* @returns Focus state and control methods
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```tsx
|
|
150
|
+
* function MenuItem({ label }: { label: string }) {
|
|
151
|
+
* const { focused, focus, blur } = useFocus({
|
|
152
|
+
* onFocus: () => console.log('focused'),
|
|
153
|
+
* onBlur: () => console.log('blurred')
|
|
154
|
+
* })
|
|
155
|
+
*
|
|
156
|
+
* return (
|
|
157
|
+
* <Text color={focused ? 'primary' : undefined}>
|
|
158
|
+
* {focused ? '> ' : ' '}{label}
|
|
159
|
+
* </Text>
|
|
160
|
+
* )
|
|
161
|
+
* }
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export function useFocus(options?: UseFocusOptions): FocusableElement {
|
|
165
|
+
const idRef = useRef(createFocusId(options?.id))
|
|
166
|
+
const [localFocused, setLocalFocused] = useState(options?.autoFocus ?? false)
|
|
167
|
+
const tabIndex = options?.tabIndex ?? 0
|
|
168
|
+
const context = useContext(FocusContext)
|
|
169
|
+
const id = idRef.current
|
|
170
|
+
|
|
171
|
+
// Register with FocusProvider on mount, unregister on unmount
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (context?.registerFocusable) {
|
|
174
|
+
context.registerFocusable(id, tabIndex)
|
|
175
|
+
return () => context.unregisterFocusable?.(id)
|
|
176
|
+
}
|
|
177
|
+
}, [id, tabIndex, context])
|
|
178
|
+
|
|
179
|
+
// Determine focused state: prefer context if available, else use local state
|
|
180
|
+
const focused = context ? context.focusedId === id : localFocused
|
|
181
|
+
|
|
182
|
+
const focus = useCallback(() => {
|
|
183
|
+
if (context) {
|
|
184
|
+
context.focusById(id)
|
|
185
|
+
} else {
|
|
186
|
+
setLocalFocused(true)
|
|
187
|
+
}
|
|
188
|
+
options?.onFocus?.()
|
|
189
|
+
}, [context, id, options])
|
|
190
|
+
|
|
191
|
+
const blur = useCallback(() => {
|
|
192
|
+
// In context mode, we don't have a direct "blur" - focus moves to another element
|
|
193
|
+
// In local mode, we set local focused to false
|
|
194
|
+
if (!context) {
|
|
195
|
+
setLocalFocused(false)
|
|
196
|
+
}
|
|
197
|
+
options?.onBlur?.()
|
|
198
|
+
}, [context, options])
|
|
199
|
+
|
|
200
|
+
const focusNext = useCallback(() => {
|
|
201
|
+
context?.focusNext()
|
|
202
|
+
}, [context])
|
|
203
|
+
|
|
204
|
+
const focusPrev = useCallback(() => {
|
|
205
|
+
context?.focusPrev()
|
|
206
|
+
}, [context])
|
|
207
|
+
|
|
208
|
+
const focusFirst = useCallback(() => {
|
|
209
|
+
context?.focusFirst()
|
|
210
|
+
}, [context])
|
|
211
|
+
|
|
212
|
+
const focusLast = useCallback(() => {
|
|
213
|
+
context?.focusLast()
|
|
214
|
+
}, [context])
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
id,
|
|
218
|
+
focused,
|
|
219
|
+
tabIndex,
|
|
220
|
+
focus,
|
|
221
|
+
blur,
|
|
222
|
+
focusNext,
|
|
223
|
+
focusPrev,
|
|
224
|
+
focusFirst,
|
|
225
|
+
focusLast,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ============================================================================
|
|
230
|
+
// useFocusManager Hook
|
|
231
|
+
// ============================================================================
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* State and controls from the focus manager context.
|
|
235
|
+
*/
|
|
236
|
+
export interface FocusManagerState {
|
|
237
|
+
/** IDs of all registered focusable elements */
|
|
238
|
+
focusableIds: FocusId[]
|
|
239
|
+
/** ID of the currently focused element, or null */
|
|
240
|
+
focusedId: FocusId | null
|
|
241
|
+
/** Whether focus is trapped within a container */
|
|
242
|
+
isTrapped: boolean
|
|
243
|
+
/** Focus a specific element by ID */
|
|
244
|
+
focusById: (id: FocusId) => void
|
|
245
|
+
/** Move focus to the next element */
|
|
246
|
+
focusNext: () => void
|
|
247
|
+
/** Move focus to the previous element */
|
|
248
|
+
focusPrev: () => void
|
|
249
|
+
/** Move focus to the first element */
|
|
250
|
+
focusFirst: () => void
|
|
251
|
+
/** Move focus to the last element */
|
|
252
|
+
focusLast: () => void
|
|
253
|
+
/** Register a focusable element with the provider */
|
|
254
|
+
registerFocusable: (id: FocusId, tabIndex: number) => void
|
|
255
|
+
/** Unregister a focusable element from the provider */
|
|
256
|
+
unregisterFocusable: (id: FocusId) => void
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* React context for focus manager state.
|
|
261
|
+
*/
|
|
262
|
+
export const FocusContext = createContext<FocusManagerState | null>(null)
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* React hook to access the focus manager context.
|
|
266
|
+
*
|
|
267
|
+
* Must be used within a FocusProvider. Returns a default
|
|
268
|
+
* no-op implementation if used outside a provider.
|
|
269
|
+
*
|
|
270
|
+
* @returns Focus manager state and controls
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* ```tsx
|
|
274
|
+
* function NavigationHelp() {
|
|
275
|
+
* const { focusFirst, focusLast } = useFocusManager()
|
|
276
|
+
*
|
|
277
|
+
* return (
|
|
278
|
+
* <Box>
|
|
279
|
+
* <Button onPress={focusFirst}>Go to start</Button>
|
|
280
|
+
* <Button onPress={focusLast}>Go to end</Button>
|
|
281
|
+
* </Box>
|
|
282
|
+
* )
|
|
283
|
+
* }
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export function useFocusManager(): FocusManagerState {
|
|
287
|
+
const context = useContext(FocusContext)
|
|
288
|
+
|
|
289
|
+
// Return context if available, otherwise return default no-op implementation
|
|
290
|
+
if (context) {
|
|
291
|
+
return context
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Default implementation when not inside a FocusProvider
|
|
295
|
+
return {
|
|
296
|
+
focusableIds: [],
|
|
297
|
+
focusedId: null,
|
|
298
|
+
isTrapped: false,
|
|
299
|
+
focusById: () => {},
|
|
300
|
+
focusNext: () => {},
|
|
301
|
+
focusPrev: () => {},
|
|
302
|
+
focusFirst: () => {},
|
|
303
|
+
focusLast: () => {},
|
|
304
|
+
registerFocusable: () => {},
|
|
305
|
+
unregisterFocusable: () => {},
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// useNavigableList Hook
|
|
311
|
+
// ============================================================================
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Options for the useNavigableList hook.
|
|
315
|
+
*/
|
|
316
|
+
export interface UseNavigableListOptions<T> {
|
|
317
|
+
/** Array of items to navigate */
|
|
318
|
+
items: T[]
|
|
319
|
+
/** Initial selected index (default: 0) */
|
|
320
|
+
initialIndex?: number
|
|
321
|
+
/** Wrap around at list boundaries (default: false) */
|
|
322
|
+
wrap?: boolean
|
|
323
|
+
/** Enable keyboard navigation (default: false) */
|
|
324
|
+
useKeyboard?: boolean
|
|
325
|
+
/** Callback when an item is selected */
|
|
326
|
+
onSelect?: (item: T, index: number) => void
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Return value from the useNavigableList hook.
|
|
331
|
+
*/
|
|
332
|
+
export interface UseNavigableListResult<T> {
|
|
333
|
+
/** Current selected index */
|
|
334
|
+
currentIndex: number
|
|
335
|
+
/** Current selected item */
|
|
336
|
+
currentItem: T | undefined
|
|
337
|
+
/** Whether wrapping is enabled */
|
|
338
|
+
wrap: boolean
|
|
339
|
+
/** Whether keyboard navigation is enabled */
|
|
340
|
+
keyboardEnabled: boolean
|
|
341
|
+
/** Move selection up (decrease index) */
|
|
342
|
+
moveUp: () => void
|
|
343
|
+
/** Move selection down (increase index) */
|
|
344
|
+
moveDown: () => void
|
|
345
|
+
/** Move selection to the first item */
|
|
346
|
+
moveToFirst: () => void
|
|
347
|
+
/** Move selection to the last item */
|
|
348
|
+
moveToLast: () => void
|
|
349
|
+
/** Set the selection to a specific index */
|
|
350
|
+
setIndex: (index: number) => void
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* React hook for keyboard-navigable lists.
|
|
355
|
+
*
|
|
356
|
+
* Manages selection state and provides navigation methods
|
|
357
|
+
* for building interactive list interfaces.
|
|
358
|
+
*
|
|
359
|
+
* @param options - Configuration options
|
|
360
|
+
* @returns Selection state and navigation controls
|
|
361
|
+
*
|
|
362
|
+
* @example
|
|
363
|
+
* ```tsx
|
|
364
|
+
* function Menu() {
|
|
365
|
+
* const items = ['File', 'Edit', 'View', 'Help']
|
|
366
|
+
* const { currentIndex, moveUp, moveDown } = useNavigableList({
|
|
367
|
+
* items,
|
|
368
|
+
* wrap: true,
|
|
369
|
+
* onSelect: (item) => console.log('Selected:', item)
|
|
370
|
+
* })
|
|
371
|
+
*
|
|
372
|
+
* return (
|
|
373
|
+
* <List items={items} selectedIndex={currentIndex} />
|
|
374
|
+
* )
|
|
375
|
+
* }
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
export function useNavigableList<T>(options: UseNavigableListOptions<T>): UseNavigableListResult<T> {
|
|
379
|
+
const { items, initialIndex = 0, wrap = false, useKeyboard: keyboardEnabled = false } = options
|
|
380
|
+
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
|
381
|
+
|
|
382
|
+
const moveUp = useCallback(() => {
|
|
383
|
+
setCurrentIndex((prev) => {
|
|
384
|
+
if (prev <= 0) {
|
|
385
|
+
return wrap ? items.length - 1 : 0
|
|
386
|
+
}
|
|
387
|
+
return prev - 1
|
|
388
|
+
})
|
|
389
|
+
}, [items.length, wrap])
|
|
390
|
+
|
|
391
|
+
const moveDown = useCallback(() => {
|
|
392
|
+
setCurrentIndex((prev) => {
|
|
393
|
+
if (prev >= items.length - 1) {
|
|
394
|
+
return wrap ? 0 : items.length - 1
|
|
395
|
+
}
|
|
396
|
+
return prev + 1
|
|
397
|
+
})
|
|
398
|
+
}, [items.length, wrap])
|
|
399
|
+
|
|
400
|
+
const moveToFirst = useCallback(() => {
|
|
401
|
+
setCurrentIndex(0)
|
|
402
|
+
}, [])
|
|
403
|
+
|
|
404
|
+
const moveToLast = useCallback(() => {
|
|
405
|
+
setCurrentIndex(items.length - 1)
|
|
406
|
+
}, [items.length])
|
|
407
|
+
|
|
408
|
+
const setIndex = useCallback(
|
|
409
|
+
(index: number) => {
|
|
410
|
+
if (index >= 0 && index < items.length) {
|
|
411
|
+
setCurrentIndex(index)
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
[items.length]
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
currentIndex,
|
|
419
|
+
currentItem: items[currentIndex],
|
|
420
|
+
wrap,
|
|
421
|
+
keyboardEnabled,
|
|
422
|
+
moveUp,
|
|
423
|
+
moveDown,
|
|
424
|
+
moveToFirst,
|
|
425
|
+
moveToLast,
|
|
426
|
+
setIndex,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// useNavigableGrid Hook
|
|
432
|
+
// ============================================================================
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Options for the useNavigableGrid hook.
|
|
436
|
+
*/
|
|
437
|
+
export interface UseNavigableGridOptions {
|
|
438
|
+
/** Number of rows in the grid */
|
|
439
|
+
rows: number
|
|
440
|
+
/** Number of columns in the grid */
|
|
441
|
+
cols: number
|
|
442
|
+
/** Initial row (default: 0) */
|
|
443
|
+
initialRow?: number
|
|
444
|
+
/** Initial column (default: 0) */
|
|
445
|
+
initialCol?: number
|
|
446
|
+
/** Wrap horizontally at column boundaries (default: false) */
|
|
447
|
+
wrapHorizontal?: boolean
|
|
448
|
+
/** Wrap vertically at row boundaries (default: false) */
|
|
449
|
+
wrapVertical?: boolean
|
|
450
|
+
/** Enable keyboard navigation (default: false) */
|
|
451
|
+
useKeyboard?: boolean
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Return value from the useNavigableGrid hook.
|
|
456
|
+
*/
|
|
457
|
+
export interface UseNavigableGridResult {
|
|
458
|
+
/** Current row position */
|
|
459
|
+
row: number
|
|
460
|
+
/** Current column position */
|
|
461
|
+
col: number
|
|
462
|
+
/** Whether horizontal wrapping is enabled */
|
|
463
|
+
wrapHorizontal: boolean
|
|
464
|
+
/** Whether vertical wrapping is enabled */
|
|
465
|
+
wrapVertical: boolean
|
|
466
|
+
/** Whether keyboard navigation is enabled */
|
|
467
|
+
keyboardEnabled: boolean
|
|
468
|
+
/** Move up one row */
|
|
469
|
+
moveUp: () => void
|
|
470
|
+
/** Move down one row */
|
|
471
|
+
moveDown: () => void
|
|
472
|
+
/** Move left one column */
|
|
473
|
+
moveLeft: () => void
|
|
474
|
+
/** Move right one column */
|
|
475
|
+
moveRight: () => void
|
|
476
|
+
/** Move to a specific cell */
|
|
477
|
+
moveToCell: (row: number, col: number) => void
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* React hook for keyboard-navigable grids.
|
|
482
|
+
*
|
|
483
|
+
* Manages 2D position state and provides navigation methods
|
|
484
|
+
* for building grid-based interactive interfaces.
|
|
485
|
+
*
|
|
486
|
+
* @param options - Configuration options
|
|
487
|
+
* @returns Position state and navigation controls
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* ```tsx
|
|
491
|
+
* function Calendar() {
|
|
492
|
+
* const { row, col, moveUp, moveDown, moveLeft, moveRight } = useNavigableGrid({
|
|
493
|
+
* rows: 5,
|
|
494
|
+
* cols: 7,
|
|
495
|
+
* wrapHorizontal: true
|
|
496
|
+
* })
|
|
497
|
+
*
|
|
498
|
+
* // Render 5x7 calendar grid with selection at [row, col]
|
|
499
|
+
* }
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
export function useNavigableGrid(options: UseNavigableGridOptions): UseNavigableGridResult {
|
|
503
|
+
const {
|
|
504
|
+
rows,
|
|
505
|
+
cols,
|
|
506
|
+
initialRow = 0,
|
|
507
|
+
initialCol = 0,
|
|
508
|
+
wrapHorizontal = false,
|
|
509
|
+
wrapVertical = false,
|
|
510
|
+
useKeyboard: keyboardEnabled = false,
|
|
511
|
+
} = options
|
|
512
|
+
|
|
513
|
+
const [row, setRow] = useState(initialRow)
|
|
514
|
+
const [col, setCol] = useState(initialCol)
|
|
515
|
+
|
|
516
|
+
const moveUp = useCallback(() => {
|
|
517
|
+
setRow((prev) => {
|
|
518
|
+
if (prev <= 0) {
|
|
519
|
+
return wrapVertical ? rows - 1 : 0
|
|
520
|
+
}
|
|
521
|
+
return prev - 1
|
|
522
|
+
})
|
|
523
|
+
}, [rows, wrapVertical])
|
|
524
|
+
|
|
525
|
+
const moveDown = useCallback(() => {
|
|
526
|
+
setRow((prev) => {
|
|
527
|
+
if (prev >= rows - 1) {
|
|
528
|
+
return wrapVertical ? 0 : rows - 1
|
|
529
|
+
}
|
|
530
|
+
return prev + 1
|
|
531
|
+
})
|
|
532
|
+
}, [rows, wrapVertical])
|
|
533
|
+
|
|
534
|
+
const moveLeft = useCallback(() => {
|
|
535
|
+
setCol((prev) => {
|
|
536
|
+
if (prev <= 0) {
|
|
537
|
+
return wrapHorizontal ? cols - 1 : 0
|
|
538
|
+
}
|
|
539
|
+
return prev - 1
|
|
540
|
+
})
|
|
541
|
+
}, [cols, wrapHorizontal])
|
|
542
|
+
|
|
543
|
+
const moveRight = useCallback(() => {
|
|
544
|
+
setCol((prev) => {
|
|
545
|
+
if (prev >= cols - 1) {
|
|
546
|
+
return wrapHorizontal ? 0 : cols - 1
|
|
547
|
+
}
|
|
548
|
+
return prev + 1
|
|
549
|
+
})
|
|
550
|
+
}, [cols, wrapHorizontal])
|
|
551
|
+
|
|
552
|
+
const moveToCell = useCallback(
|
|
553
|
+
(newRow: number, newCol: number) => {
|
|
554
|
+
if (newRow >= 0 && newRow < rows) {
|
|
555
|
+
setRow(newRow)
|
|
556
|
+
}
|
|
557
|
+
if (newCol >= 0 && newCol < cols) {
|
|
558
|
+
setCol(newCol)
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
[rows, cols]
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
row,
|
|
566
|
+
col,
|
|
567
|
+
wrapHorizontal,
|
|
568
|
+
wrapVertical,
|
|
569
|
+
keyboardEnabled,
|
|
570
|
+
moveUp,
|
|
571
|
+
moveDown,
|
|
572
|
+
moveLeft,
|
|
573
|
+
moveRight,
|
|
574
|
+
moveToCell,
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ============================================================================
|
|
579
|
+
// FocusProvider Component
|
|
580
|
+
// ============================================================================
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Props for the FocusProvider component.
|
|
584
|
+
*/
|
|
585
|
+
export interface FocusProviderProps {
|
|
586
|
+
/** Child components */
|
|
587
|
+
children?: ReactNode
|
|
588
|
+
/** Trap focus within this provider (default: false) */
|
|
589
|
+
trap?: boolean
|
|
590
|
+
/** Wrap focus when reaching boundaries (default: false) */
|
|
591
|
+
wrapFocus?: boolean
|
|
592
|
+
/** ID of element to focus initially */
|
|
593
|
+
initialFocus?: FocusId
|
|
594
|
+
/** Focus first element on mount (default: false) */
|
|
595
|
+
focusFirstOnMount?: boolean
|
|
596
|
+
/** Restore focus to previous element when provider unmounts */
|
|
597
|
+
restoreFocus?: boolean
|
|
598
|
+
/** Group name for nested focus management */
|
|
599
|
+
group?: string
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Focus provider component for managing focus state across components.
|
|
604
|
+
*
|
|
605
|
+
* Provides focus management context to child components, enabling
|
|
606
|
+
* coordinated focus navigation within a container.
|
|
607
|
+
*
|
|
608
|
+
* **Features:**
|
|
609
|
+
* - Focus tracking across registered focusable elements
|
|
610
|
+
* - Focus trapping for modals and dialogs
|
|
611
|
+
* - Wrap-around navigation at boundaries
|
|
612
|
+
* - Initial focus and focus restoration support
|
|
613
|
+
* - Named focus groups for scoped management
|
|
614
|
+
*
|
|
615
|
+
* @param props - Configuration and children
|
|
616
|
+
* @returns React element with focus context
|
|
617
|
+
*
|
|
618
|
+
* @example
|
|
619
|
+
* ```tsx
|
|
620
|
+
* function Dialog() {
|
|
621
|
+
* return (
|
|
622
|
+
* <FocusProvider trap focusFirstOnMount>
|
|
623
|
+
* <Input label="Name" />
|
|
624
|
+
* <Input label="Email" />
|
|
625
|
+
* <Button>Submit</Button>
|
|
626
|
+
* </FocusProvider>
|
|
627
|
+
* )
|
|
628
|
+
* }
|
|
629
|
+
* ```
|
|
630
|
+
*/
|
|
631
|
+
export function FocusProvider(props: FocusProviderProps): ReactElement {
|
|
632
|
+
const { children, trap = false, wrapFocus = false, initialFocus, focusFirstOnMount = false, restoreFocus = false, group } = props
|
|
633
|
+
|
|
634
|
+
// Track registered focusable elements
|
|
635
|
+
const [focusableIds, setFocusableIds] = useState<FocusId[]>([])
|
|
636
|
+
const [focusedId, setFocusedId] = useState<FocusId | null>(initialFocus ?? null)
|
|
637
|
+
const previousFocusRef = useRef<FocusId | null>(null)
|
|
638
|
+
// Track tabIndex for each focusable element for sorting
|
|
639
|
+
const tabIndexMapRef = useRef<Map<FocusId, number>>(new Map())
|
|
640
|
+
|
|
641
|
+
// Focus first element on mount if configured
|
|
642
|
+
useEffect(() => {
|
|
643
|
+
if (focusFirstOnMount && focusableIds.length > 0 && !focusedId) {
|
|
644
|
+
setFocusedId(focusableIds[0])
|
|
645
|
+
}
|
|
646
|
+
}, [focusFirstOnMount, focusableIds, focusedId])
|
|
647
|
+
|
|
648
|
+
// Restore focus on unmount if configured
|
|
649
|
+
useEffect(() => {
|
|
650
|
+
if (restoreFocus) {
|
|
651
|
+
previousFocusRef.current = focusedId
|
|
652
|
+
}
|
|
653
|
+
return () => {
|
|
654
|
+
if (restoreFocus && previousFocusRef.current) {
|
|
655
|
+
// Focus restoration would happen here in a real DOM environment
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}, [restoreFocus, focusedId])
|
|
659
|
+
|
|
660
|
+
const focusById = useCallback((id: FocusId) => {
|
|
661
|
+
if (focusableIds.includes(id)) {
|
|
662
|
+
setFocusedId(id)
|
|
663
|
+
}
|
|
664
|
+
}, [focusableIds])
|
|
665
|
+
|
|
666
|
+
const focusNext = useCallback(() => {
|
|
667
|
+
if (focusableIds.length === 0) return
|
|
668
|
+
const currentIndex = focusedId ? focusableIds.indexOf(focusedId) : -1
|
|
669
|
+
let nextIndex = currentIndex + 1
|
|
670
|
+
if (nextIndex >= focusableIds.length) {
|
|
671
|
+
nextIndex = wrapFocus ? 0 : focusableIds.length - 1
|
|
672
|
+
}
|
|
673
|
+
setFocusedId(focusableIds[nextIndex])
|
|
674
|
+
}, [focusableIds, focusedId, wrapFocus])
|
|
675
|
+
|
|
676
|
+
const focusPrev = useCallback(() => {
|
|
677
|
+
if (focusableIds.length === 0) return
|
|
678
|
+
const currentIndex = focusedId ? focusableIds.indexOf(focusedId) : focusableIds.length
|
|
679
|
+
let prevIndex = currentIndex - 1
|
|
680
|
+
if (prevIndex < 0) {
|
|
681
|
+
prevIndex = wrapFocus ? focusableIds.length - 1 : 0
|
|
682
|
+
}
|
|
683
|
+
setFocusedId(focusableIds[prevIndex])
|
|
684
|
+
}, [focusableIds, focusedId, wrapFocus])
|
|
685
|
+
|
|
686
|
+
const focusFirst = useCallback(() => {
|
|
687
|
+
if (focusableIds.length > 0) {
|
|
688
|
+
setFocusedId(focusableIds[0])
|
|
689
|
+
}
|
|
690
|
+
}, [focusableIds])
|
|
691
|
+
|
|
692
|
+
const focusLast = useCallback(() => {
|
|
693
|
+
if (focusableIds.length > 0) {
|
|
694
|
+
setFocusedId(focusableIds[focusableIds.length - 1])
|
|
695
|
+
}
|
|
696
|
+
}, [focusableIds])
|
|
697
|
+
|
|
698
|
+
const registerFocusable = useCallback((id: FocusId, tabIndex: number) => {
|
|
699
|
+
tabIndexMapRef.current.set(id, tabIndex)
|
|
700
|
+
setFocusableIds(prev => {
|
|
701
|
+
if (prev.includes(id)) return prev
|
|
702
|
+
const newIds = [...prev, id]
|
|
703
|
+
// Sort by tabIndex (lower values first)
|
|
704
|
+
newIds.sort((a, b) => {
|
|
705
|
+
const tabA = tabIndexMapRef.current.get(a) ?? 0
|
|
706
|
+
const tabB = tabIndexMapRef.current.get(b) ?? 0
|
|
707
|
+
return tabA - tabB
|
|
708
|
+
})
|
|
709
|
+
return newIds
|
|
710
|
+
})
|
|
711
|
+
}, [])
|
|
712
|
+
|
|
713
|
+
const unregisterFocusable = useCallback((id: FocusId) => {
|
|
714
|
+
tabIndexMapRef.current.delete(id)
|
|
715
|
+
setFocusableIds(prev => prev.filter(fid => fid !== id))
|
|
716
|
+
}, [])
|
|
717
|
+
|
|
718
|
+
const contextValue: FocusManagerState = {
|
|
719
|
+
focusableIds,
|
|
720
|
+
focusedId,
|
|
721
|
+
isTrapped: trap,
|
|
722
|
+
focusById,
|
|
723
|
+
focusNext,
|
|
724
|
+
focusPrev,
|
|
725
|
+
focusFirst,
|
|
726
|
+
focusLast,
|
|
727
|
+
registerFocusable,
|
|
728
|
+
unregisterFocusable,
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Render a div wrapper that passes through all props for test compatibility
|
|
732
|
+
// and provides focus context to children
|
|
733
|
+
return React.createElement(
|
|
734
|
+
FocusContext.Provider,
|
|
735
|
+
{ value: contextValue },
|
|
736
|
+
React.createElement(
|
|
737
|
+
'div',
|
|
738
|
+
{
|
|
739
|
+
'data-focus-provider': true,
|
|
740
|
+
'data-focus-group': group,
|
|
741
|
+
'data-focus-trap': trap,
|
|
742
|
+
...props,
|
|
743
|
+
children: undefined, // Remove children from div props to avoid duplication
|
|
744
|
+
},
|
|
745
|
+
children
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
}
|