@rlabs-inc/tui 0.3.2 → 0.6.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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * TUI Framework - Reactive Context System
3
+ *
4
+ * Provides React-like context for passing data deep without prop drilling.
5
+ * Uses ReactiveMap for automatic reactive subscriptions - no explicit
6
+ * subscribe/unsubscribe needed!
7
+ *
8
+ * How it works:
9
+ * - ReactiveMap has per-key fine-grained reactivity
10
+ * - Reading from the map inside a derived/effect auto-subscribes
11
+ * - Writing to the map auto-notifies all readers of that key
12
+ *
13
+ * Usage:
14
+ * ```ts
15
+ * // Create a context
16
+ * const ThemeContext = createContext<Theme>(defaultTheme)
17
+ *
18
+ * // Provider sets it
19
+ * provide(ThemeContext, darkTheme)
20
+ *
21
+ * // Consumer reads it - automatically reactive!
22
+ * function ThemedBox() {
23
+ * const theme = useContext(ThemeContext)
24
+ * return box({ bg: theme.background })
25
+ * }
26
+ *
27
+ * // Update context - all consumers re-render
28
+ * provide(ThemeContext, lightTheme)
29
+ * ```
30
+ */
31
+
32
+ import { ReactiveMap, signal, type WritableSignal } from '@rlabs-inc/signals'
33
+
34
+ // =============================================================================
35
+ // Context Types
36
+ // =============================================================================
37
+
38
+ /**
39
+ * A context identifier with type information.
40
+ * The symbol ensures uniqueness, the type parameter ensures type safety.
41
+ */
42
+ export interface Context<T> {
43
+ /** Unique identifier for this context */
44
+ readonly id: symbol
45
+ /** Default value if no provider exists */
46
+ readonly defaultValue: T
47
+ /** Display name for debugging */
48
+ readonly displayName?: string
49
+ }
50
+
51
+ // =============================================================================
52
+ // Context Storage
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Global reactive storage for all context values.
57
+ *
58
+ * Using ReactiveMap means:
59
+ * - get(key) inside derived/effect = auto-subscribe to that key
60
+ * - set(key, value) = auto-notify all subscribers of that key
61
+ *
62
+ * This is the magic that makes context automatically reactive!
63
+ */
64
+ const contextValues = new ReactiveMap<symbol, unknown>()
65
+
66
+ /**
67
+ * Signal wrappers for contexts that need .value access pattern.
68
+ * When a user provides a signal, we store it directly.
69
+ * When they provide a static value, we wrap it in a signal.
70
+ */
71
+ const contextSignals = new ReactiveMap<symbol, WritableSignal<unknown>>()
72
+
73
+ // =============================================================================
74
+ // Context API
75
+ // =============================================================================
76
+
77
+ /**
78
+ * Create a new context with a default value.
79
+ *
80
+ * @param defaultValue - Value used when no provider exists
81
+ * @param displayName - Optional name for debugging
82
+ * @returns A Context object to use with provide() and useContext()
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * const UserContext = createContext<User | null>(null)
87
+ * const ThemeContext = createContext(defaultTheme, 'Theme')
88
+ * ```
89
+ */
90
+ export function createContext<T>(defaultValue: T, displayName?: string): Context<T> {
91
+ return {
92
+ id: Symbol(displayName ?? 'Context'),
93
+ defaultValue,
94
+ displayName,
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Provide a value for a context.
100
+ *
101
+ * Can be called multiple times to update the context value.
102
+ * All consumers using useContext() will automatically update.
103
+ *
104
+ * @param context - The context to provide
105
+ * @param value - The value to provide (static or signal)
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * // Static value
110
+ * provide(ThemeContext, darkTheme)
111
+ *
112
+ * // Signal - consumers react to signal changes too!
113
+ * const theme = signal(darkTheme)
114
+ * provide(ThemeContext, theme)
115
+ * theme.value = lightTheme // All consumers update!
116
+ * ```
117
+ */
118
+ export function provide<T>(context: Context<T>, value: T | WritableSignal<T>): void {
119
+ // Check if value is a signal from @rlabs-inc/signals
120
+ // Signals have a Symbol('signal.source') property that plain objects don't have
121
+ // This is more reliable than just checking for 'value' since regular objects
122
+ // might have a 'value' property but won't have the internal signal symbol
123
+ if (
124
+ value !== null &&
125
+ typeof value === 'object' &&
126
+ 'value' in value &&
127
+ Object.getOwnPropertySymbols(value).some((sym) => sym.description === 'signal.source')
128
+ ) {
129
+ // It's a signal - store it directly for reactive access
130
+ contextSignals.set(context.id, value as WritableSignal<unknown>)
131
+ contextValues.set(context.id, value)
132
+ } else {
133
+ // Static value - wrap in a signal for consistent access
134
+ const existing = contextSignals.get(context.id)
135
+ if (existing) {
136
+ // Update existing signal
137
+ existing.value = value
138
+ } else {
139
+ // Create new signal
140
+ const sig = signal(value)
141
+ contextSignals.set(context.id, sig as WritableSignal<unknown>)
142
+ }
143
+ contextValues.set(context.id, value)
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get the current value of a context.
149
+ *
150
+ * This is automatically reactive when used inside:
151
+ * - derived(() => ...)
152
+ * - effect(() => ...)
153
+ * - Component prop getters: () => useContext(...)
154
+ *
155
+ * @param context - The context to read
156
+ * @returns The current context value, or defaultValue if no provider
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * function MyComponent() {
161
+ * const theme = useContext(ThemeContext)
162
+ * return box({ bg: theme.background })
163
+ * }
164
+ * ```
165
+ */
166
+ export function useContext<T>(context: Context<T>): T {
167
+ // Check for signal wrapper first (for .value reactivity)
168
+ const sig = contextSignals.get(context.id)
169
+ if (sig !== undefined) {
170
+ return sig.value as T
171
+ }
172
+
173
+ // Fall back to direct value lookup (still reactive via ReactiveMap!)
174
+ const value = contextValues.get(context.id)
175
+ if (value !== undefined) {
176
+ return value as T
177
+ }
178
+
179
+ return context.defaultValue
180
+ }
181
+
182
+ /**
183
+ * Check if a context has been provided.
184
+ *
185
+ * @param context - The context to check
186
+ * @returns true if provide() has been called for this context
187
+ */
188
+ export function hasContext<T>(context: Context<T>): boolean {
189
+ return contextValues.has(context.id)
190
+ }
191
+
192
+ /**
193
+ * Clear a context, returning to default value.
194
+ *
195
+ * @param context - The context to clear
196
+ */
197
+ export function clearContext<T>(context: Context<T>): void {
198
+ contextValues.delete(context.id)
199
+ contextSignals.delete(context.id)
200
+ }
201
+
202
+ // =============================================================================
203
+ // Reset (for testing)
204
+ // =============================================================================
205
+
206
+ /**
207
+ * Reset all context state (for testing)
208
+ */
209
+ export function resetContexts(): void {
210
+ contextValues.clear()
211
+ contextSignals.clear()
212
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * TUI Framework - Drawn Cursor State
3
+ *
4
+ * Manages cursor state for input components (input, textarea, custom editors).
5
+ * Unlike the terminal native cursor (cursor.ts), this cursor is drawn into
6
+ * the frameBuffer and supports full customization.
7
+ *
8
+ * Features:
9
+ * - Style presets (block, bar, underline) or custom characters
10
+ * - Blink animation with configurable FPS (default: 2 FPS)
11
+ * - Custom colors (fg/bg) or inherit from component
12
+ * - Alt character for blink "off" phase
13
+ * - Integrates with animation module for efficient shared clocks
14
+ *
15
+ * Usage:
16
+ * ```ts
17
+ * import { createCursor, disposeCursor } from '../state/drawnCursor'
18
+ *
19
+ * // In input component:
20
+ * const cursor = createCursor(index, {
21
+ * style: 'bar',
22
+ * blink: true,
23
+ * fps: 2,
24
+ * })
25
+ *
26
+ * // Update position
27
+ * cursor.setPosition(5)
28
+ *
29
+ * // Cleanup on unmount
30
+ * disposeCursor(index)
31
+ * ```
32
+ */
33
+
34
+ import { signal } from '@rlabs-inc/signals'
35
+ import * as interaction from '../engine/arrays/interaction'
36
+ import { registerFocusCallbacks } from './focus'
37
+ import { onDestroy } from '../engine/lifecycle'
38
+
39
+ // =============================================================================
40
+ // TYPES
41
+ // =============================================================================
42
+
43
+ export type DrawnCursorStyle = 'block' | 'bar' | 'underline'
44
+
45
+ export interface DrawnCursorConfig {
46
+ /** Cursor style preset */
47
+ style?: DrawnCursorStyle
48
+ /** Custom cursor character (overrides style) */
49
+ char?: string
50
+ /** Enable blink animation (default: true) */
51
+ blink?: boolean
52
+ /** Blink FPS (default: 2, which gives 500ms on/off cycle) */
53
+ fps?: number
54
+ /** Alt character for blink "off" phase (default: invisible/space) */
55
+ altChar?: string
56
+ /** Initial cursor position */
57
+ position?: number
58
+ }
59
+
60
+ export interface DrawnCursor {
61
+ /** Set cursor position in text */
62
+ setPosition: (pos: number) => void
63
+ /** Get current position */
64
+ getPosition: () => number
65
+ /** Manually show cursor (override blink) */
66
+ show: () => void
67
+ /** Manually hide cursor (override blink) */
68
+ hide: () => void
69
+ /** Check if cursor is currently visible */
70
+ isVisible: () => boolean
71
+ /** Cleanup - stops animation, clears arrays */
72
+ dispose: () => void
73
+ }
74
+
75
+ // =============================================================================
76
+ // CURSOR CHARACTER CODEPOINTS
77
+ // =============================================================================
78
+
79
+ const CURSOR_CHARS: Record<DrawnCursorStyle, number> = {
80
+ block: 0, // 0 = special case: inverse block (swap fg/bg)
81
+ bar: 0x2502, // │ vertical line
82
+ underline: 0x5F, // _ underscore
83
+ }
84
+
85
+ // =============================================================================
86
+ // BLINK ANIMATION REGISTRY
87
+ // Shared clocks per FPS - same pattern as animation.ts
88
+ // =============================================================================
89
+
90
+ interface BlinkRegistry {
91
+ phase: ReturnType<typeof signal<boolean>>
92
+ interval: ReturnType<typeof setInterval> | null
93
+ subscribers: number
94
+ }
95
+
96
+ const blinkRegistry = new Map<number, BlinkRegistry>()
97
+
98
+ function getBlinkClock(fps: number): BlinkRegistry {
99
+ let registry = blinkRegistry.get(fps)
100
+
101
+ if (!registry) {
102
+ registry = {
103
+ phase: signal(true), // true = visible
104
+ interval: null,
105
+ subscribers: 0,
106
+ }
107
+ blinkRegistry.set(fps, registry)
108
+ }
109
+
110
+ return registry
111
+ }
112
+
113
+ function subscribeToBlink(fps: number): () => void {
114
+ // Guard against invalid fps (0 or negative would cause Infinity interval)
115
+ if (fps <= 0) {
116
+ return () => {} // No-op unsubscribe
117
+ }
118
+
119
+ const registry = getBlinkClock(fps)
120
+ registry.subscribers++
121
+
122
+ // Start interval if first subscriber
123
+ if (registry.subscribers === 1 && !registry.interval) {
124
+ const ms = Math.floor(1000 / fps / 2) // Divide by 2 for on/off cycle
125
+ registry.interval = setInterval(() => {
126
+ registry.phase.value = !registry.phase.value
127
+ }, ms)
128
+ }
129
+
130
+ return () => {
131
+ registry.subscribers = Math.max(0, registry.subscribers - 1)
132
+
133
+ // Stop interval if no more subscribers
134
+ if (registry.subscribers === 0 && registry.interval) {
135
+ clearInterval(registry.interval)
136
+ registry.interval = null
137
+ registry.phase.value = true // Reset to visible
138
+ }
139
+ }
140
+ }
141
+
142
+ // =============================================================================
143
+ // ACTIVE CURSORS REGISTRY
144
+ // =============================================================================
145
+
146
+ const activeCursors = new Map<number, {
147
+ unsubscribeBlink: (() => void) | null
148
+ unsubscribeFocus: () => void
149
+ manualVisible: ReturnType<typeof signal<boolean | null>>
150
+ }>()
151
+
152
+ // =============================================================================
153
+ // CREATE CURSOR
154
+ // =============================================================================
155
+
156
+ /**
157
+ * Create a drawn cursor for a component.
158
+ *
159
+ * @param index Component index
160
+ * @param config Cursor configuration
161
+ * @returns Cursor control object
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * const cursor = createCursor(index, {
166
+ * style: 'bar', // 'block' | 'bar' | 'underline'
167
+ * blink: true, // default: true
168
+ * fps: 2, // default: 2 (500ms cycle)
169
+ * })
170
+ *
171
+ * cursor.setPosition(5)
172
+ * ```
173
+ */
174
+ export function createCursor(index: number, config: DrawnCursorConfig = {}): DrawnCursor {
175
+ const {
176
+ style = 'block',
177
+ char,
178
+ blink = true,
179
+ fps = 2,
180
+ altChar,
181
+ position = 0,
182
+ } = config
183
+
184
+ // Determine cursor character codepoint
185
+ let charCode: number
186
+ if (char) {
187
+ charCode = char.codePointAt(0) ?? 0
188
+ } else {
189
+ charCode = CURSOR_CHARS[style]
190
+ }
191
+
192
+ // Determine alt character for blink "off" phase
193
+ const altCharCode = altChar ? (altChar.codePointAt(0) ?? 0) : 0
194
+
195
+ // Set cursor arrays
196
+ interaction.cursorChar.setSource(index, charCode)
197
+ interaction.cursorAltChar.setSource(index, altCharCode)
198
+ interaction.cursorBlinkFps.setSource(index, blink ? fps : 0)
199
+ interaction.cursorPosition.setSource(index, position)
200
+
201
+ // Manual visibility override (null = use blink, true/false = override)
202
+ const manualVisible = signal<boolean | null>(null)
203
+
204
+ // Blink subscription (managed by focus callbacks, not effect)
205
+ let unsubscribeBlink: (() => void) | null = null
206
+ const shouldBlink = blink && fps > 0
207
+
208
+ // Register focus callbacks to start/stop blink (imperative, at the source)
209
+ const unsubscribeFocus = registerFocusCallbacks(index, {
210
+ onFocus: () => {
211
+ if (shouldBlink && !unsubscribeBlink) {
212
+ unsubscribeBlink = subscribeToBlink(fps)
213
+ }
214
+ },
215
+ onBlur: () => {
216
+ if (unsubscribeBlink) {
217
+ unsubscribeBlink()
218
+ unsubscribeBlink = null
219
+ }
220
+ },
221
+ })
222
+
223
+ // Cursor visibility as derived getter (reactive, no effect)
224
+ interaction.cursorVisible.setSource(index, () => {
225
+ // Manual override takes precedence
226
+ if (manualVisible.value !== null) {
227
+ return manualVisible.value ? 1 : 0
228
+ }
229
+ // Not focused = always visible (cursor shows but doesn't blink)
230
+ if (interaction.focusedIndex.value !== index) {
231
+ return 1
232
+ }
233
+ // Focused + blink disabled = always visible
234
+ if (!shouldBlink) {
235
+ return 1
236
+ }
237
+ // Focused + blink enabled = follow blink clock
238
+ return getBlinkClock(fps).phase.value ? 1 : 0
239
+ })
240
+
241
+ // Store in registry for cleanup
242
+ activeCursors.set(index, {
243
+ unsubscribeBlink,
244
+ unsubscribeFocus,
245
+ manualVisible,
246
+ })
247
+
248
+ // Register safety-net cleanup with lifecycle system.
249
+ // This ensures cursor is disposed even if component forgets to call dispose()
250
+ // or an error path skips cleanup. disposeCursor() is idempotent (safe to call twice).
251
+ onDestroy(() => {
252
+ disposeCursor(index)
253
+ })
254
+
255
+ // Return control object
256
+ return {
257
+ setPosition(pos: number) {
258
+ interaction.cursorPosition.setSource(index, pos)
259
+ },
260
+
261
+ getPosition() {
262
+ return interaction.cursorPosition[index] || 0
263
+ },
264
+
265
+ show() {
266
+ manualVisible.value = true
267
+ },
268
+
269
+ hide() {
270
+ manualVisible.value = false
271
+ },
272
+
273
+ isVisible() {
274
+ return (interaction.cursorVisible[index] ?? 1) === 1
275
+ },
276
+
277
+ dispose() {
278
+ disposeCursor(index)
279
+ },
280
+ }
281
+ }
282
+
283
+ // =============================================================================
284
+ // DISPOSE CURSOR
285
+ // =============================================================================
286
+
287
+ /**
288
+ * Dispose a cursor and clean up its resources.
289
+ *
290
+ * @param index Component index
291
+ */
292
+ export function disposeCursor(index: number): void {
293
+ const cursor = activeCursors.get(index)
294
+
295
+ if (cursor) {
296
+ // Unsubscribe from focus callbacks
297
+ cursor.unsubscribeFocus()
298
+
299
+ // Unsubscribe from blink (if active)
300
+ cursor.unsubscribeBlink?.()
301
+
302
+ // Remove from registry
303
+ activeCursors.delete(index)
304
+ }
305
+
306
+ // Clear cursor arrays
307
+ interaction.cursorChar.clear(index)
308
+ interaction.cursorAltChar.clear(index)
309
+ interaction.cursorBlinkFps.clear(index)
310
+ interaction.cursorVisible.clear(index)
311
+ // Note: cursorPosition is managed by component, don't clear here
312
+ }
313
+
314
+ // =============================================================================
315
+ // UTILITY EXPORTS
316
+ // =============================================================================
317
+
318
+ /** Get cursor style codepoint */
319
+ export function getCursorCharCode(style: DrawnCursorStyle): number {
320
+ return CURSOR_CHARS[style]
321
+ }
322
+
323
+ /** Check if a component has an active cursor */
324
+ export function hasCursor(index: number): boolean {
325
+ return activeCursors.has(index)
326
+ }
@@ -11,11 +11,70 @@
11
11
  import { signal, derived, unwrap } from '@rlabs-inc/signals'
12
12
  import { focusable, tabIndex, focusedIndex as _focusedIndex } from '../engine/arrays/interaction'
13
13
  import { visible } from '../engine/arrays/core'
14
- import { getAllocatedIndices } from '../engine/registry'
14
+ import { getAllocatedIndices, getId } from '../engine/registry'
15
15
 
16
16
  // Re-export the focusedIndex from interaction arrays
17
17
  export const focusedIndex = _focusedIndex
18
18
 
19
+ // =============================================================================
20
+ // FOCUS CALLBACKS (event integration at the source)
21
+ // Supports MULTIPLE callback registrations per index (e.g., cursor blink + user callback)
22
+ // =============================================================================
23
+
24
+ interface FocusCallbacks {
25
+ onFocus?: () => void
26
+ onBlur?: () => void
27
+ }
28
+
29
+ // Store arrays of callbacks - multiple registrations allowed per index
30
+ const focusCallbackRegistry = new Map<number, FocusCallbacks[]>()
31
+
32
+ /** Register focus callbacks for a component - returns unsubscribe function */
33
+ export function registerFocusCallbacks(index: number, callbacks: FocusCallbacks): () => void {
34
+ let list = focusCallbackRegistry.get(index)
35
+ if (!list) {
36
+ list = []
37
+ focusCallbackRegistry.set(index, list)
38
+ }
39
+ list.push(callbacks)
40
+
41
+ return () => {
42
+ const arr = focusCallbackRegistry.get(index)
43
+ if (arr) {
44
+ const idx = arr.indexOf(callbacks)
45
+ if (idx >= 0) arr.splice(idx, 1)
46
+ if (arr.length === 0) focusCallbackRegistry.delete(index)
47
+ }
48
+ }
49
+ }
50
+
51
+ /** Internal: Set focus and fire callbacks at the source */
52
+ function setFocusWithCallbacks(newIndex: number): void {
53
+ const oldIndex = focusedIndex.value
54
+
55
+ // No change, no callbacks
56
+ if (oldIndex === newIndex) return
57
+
58
+ // Fire onBlur for all callbacks on old focus
59
+ if (oldIndex >= 0) {
60
+ const callbacks = focusCallbackRegistry.get(oldIndex)
61
+ if (callbacks) {
62
+ for (const cb of callbacks) cb.onBlur?.()
63
+ }
64
+ }
65
+
66
+ // Update reactive state
67
+ focusedIndex.value = newIndex
68
+
69
+ // Fire onFocus for all callbacks on new focus
70
+ if (newIndex >= 0) {
71
+ const callbacks = focusCallbackRegistry.get(newIndex)
72
+ if (callbacks) {
73
+ for (const cb of callbacks) cb.onFocus?.()
74
+ }
75
+ }
76
+ }
77
+
19
78
  // =============================================================================
20
79
  // FOCUS TRAP (for modals/dialogs)
21
80
  // =============================================================================
@@ -46,14 +105,20 @@ export function getFocusTrapContainer(): number | undefined {
46
105
  // FOCUS HISTORY (for restoration)
47
106
  // =============================================================================
48
107
 
49
- const focusHistory: number[] = []
108
+ interface FocusHistoryEntry {
109
+ index: number
110
+ id: string | undefined
111
+ }
112
+
113
+ const focusHistory: FocusHistoryEntry[] = []
50
114
  const MAX_HISTORY = 10
51
115
 
52
116
  /** Save current focus to history */
53
117
  export function saveFocusToHistory(): void {
54
118
  const current = focusedIndex.value
55
119
  if (current >= 0) {
56
- focusHistory.push(current)
120
+ const id = getId(current)
121
+ focusHistory.push({ index: current, id })
57
122
  if (focusHistory.length > MAX_HISTORY) {
58
123
  focusHistory.shift()
59
124
  }
@@ -63,13 +128,15 @@ export function saveFocusToHistory(): void {
63
128
  /** Restore focus from history */
64
129
  export function restoreFocusFromHistory(): boolean {
65
130
  while (focusHistory.length > 0) {
66
- const index = focusHistory.pop()!
131
+ const entry = focusHistory.pop()!
132
+ // Verify the index hasn't been recycled for a different component
133
+ if (getId(entry.index) !== entry.id) continue
67
134
  // Check if component is still valid and focusable
68
135
  // Match TITAN's logic: undefined means visible, only 0/false means hidden
69
- const isVisible = unwrap(visible[index])
136
+ const isVisible = unwrap(visible[entry.index])
70
137
  const isActuallyVisible = isVisible !== 0 && isVisible !== false
71
- if (unwrap(focusable[index]) && isActuallyVisible) {
72
- focusedIndex.value = index
138
+ if (unwrap(focusable[entry.index]) && isActuallyVisible) {
139
+ setFocusWithCallbacks(entry.index)
73
140
  return true
74
141
  }
75
142
  }
@@ -99,7 +166,7 @@ export function getFocusableIndices(): number[] {
99
166
  result.sort((a, b) => {
100
167
  const tabA = unwrap(tabIndex[a]) ?? 0
101
168
  const tabB = unwrap(tabIndex[b]) ?? 0
102
- if (tabA !== tabB) return tabA - tabB
169
+ if (tabA !== tabB) return tabA < tabB ? -1 : 1
103
170
  return a - b // Stable sort by index
104
171
  })
105
172
 
@@ -152,7 +219,7 @@ export function focusNext(): boolean {
152
219
  const next = findNextFocusable(current, 1)
153
220
  if (next !== -1 && next !== current) {
154
221
  saveFocusToHistory()
155
- focusedIndex.value = next
222
+ setFocusWithCallbacks(next)
156
223
  return true
157
224
  }
158
225
  return false
@@ -163,7 +230,7 @@ export function focusPrevious(): boolean {
163
230
  const prev = findNextFocusable(focusedIndex.value, -1)
164
231
  if (prev !== -1 && prev !== focusedIndex.value) {
165
232
  saveFocusToHistory()
166
- focusedIndex.value = prev
233
+ setFocusWithCallbacks(prev)
167
234
  return true
168
235
  }
169
236
  return false
@@ -177,7 +244,7 @@ export function focus(index: number): boolean {
177
244
  if (unwrap(focusable[index]) && isActuallyVisible) {
178
245
  if (focusedIndex.value !== index) {
179
246
  saveFocusToHistory()
180
- focusedIndex.value = index
247
+ setFocusWithCallbacks(index)
181
248
  }
182
249
  return true
183
250
  }
@@ -188,7 +255,7 @@ export function focus(index: number): boolean {
188
255
  export function blur(): void {
189
256
  if (focusedIndex.value >= 0) {
190
257
  saveFocusToHistory()
191
- focusedIndex.value = -1
258
+ setFocusWithCallbacks(-1)
192
259
  }
193
260
  }
194
261
 
@@ -216,9 +283,10 @@ export function focusLast(): boolean {
216
283
 
217
284
  /** Reset all focus state (for testing) */
218
285
  export function resetFocusState(): void {
219
- focusedIndex.value = -1
286
+ setFocusWithCallbacks(-1)
220
287
  focusTrapStack.length = 0
221
288
  focusHistory.length = 0
289
+ focusCallbackRegistry.clear()
222
290
  }
223
291
 
224
292
  // =============================================================================
@@ -249,4 +317,7 @@ export const focusManager = {
249
317
  // History
250
318
  saveFocusToHistory,
251
319
  restoreFocusFromHistory,
320
+
321
+ // Callbacks (event integration at source)
322
+ registerFocusCallbacks,
252
323
  }