@silvery/tea 0.3.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,491 @@
1
+ /**
2
+ * Focus Manager — standalone state container for the silvery focus system.
3
+ *
4
+ * Pure TypeScript, no React dependency. The subscribe/getSnapshot pattern
5
+ * enables useSyncExternalStore in hooks.
6
+ *
7
+ * Replaces the flat focus list in context.ts (FocusContext with focusables Map).
8
+ */
9
+
10
+ import type { TeaNode, Rect } from "./types"
11
+ import {
12
+ findByTestID,
13
+ findFocusableAncestor,
14
+ getTabOrder,
15
+ findSpatialTarget,
16
+ getExplicitFocusLink,
17
+ } from "./focus-queries"
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export type FocusOrigin = "keyboard" | "mouse" | "programmatic"
24
+
25
+ /**
26
+ * Callback fired when focus changes. Used by the runtime to dispatch
27
+ * DOM-level focus/blur events without coupling FocusManager to the event system.
28
+ *
29
+ * @param oldNode - The node losing focus (null if nothing was focused)
30
+ * @param newNode - The node gaining focus (null on blur)
31
+ * @param origin - How focus was acquired
32
+ */
33
+ export type FocusChangeCallback = (oldNode: TeaNode | null, newNode: TeaNode | null, origin: FocusOrigin | null) => void
34
+
35
+ export interface FocusSnapshot {
36
+ activeId: string | null
37
+ previousId: string | null
38
+ focusOrigin: FocusOrigin | null
39
+ scopeStack: readonly string[]
40
+ /** The currently active peer scope (WPF FocusScope model) */
41
+ activeScopeId: string | null
42
+ }
43
+
44
+ export interface FocusManagerOptions {
45
+ /** Called when focus changes — wire up event dispatch here */
46
+ onFocusChange?: FocusChangeCallback
47
+ }
48
+
49
+ export interface FocusManager {
50
+ /** Currently focused node */
51
+ readonly activeElement: TeaNode | null
52
+ /** testID of the currently focused node */
53
+ readonly activeId: string | null
54
+ /** Previously focused node */
55
+ readonly previousElement: TeaNode | null
56
+ /** testID of the previously focused node */
57
+ readonly previousId: string | null
58
+ /** How focus was most recently acquired */
59
+ readonly focusOrigin: FocusOrigin | null
60
+ /** Stack of active focus scope IDs */
61
+ readonly scopeStack: readonly string[]
62
+ /** Map of scope ID -> last focused testID within that scope */
63
+ readonly scopeMemory: Readonly<Record<string, string>>
64
+
65
+ /** Focus a specific node */
66
+ focus(node: TeaNode, origin?: FocusOrigin): void
67
+ /** Focus a node by testID (requires root for tree search) */
68
+ focusById(id: string, root: TeaNode, origin?: FocusOrigin): void
69
+ /** Clear focus */
70
+ blur(): void
71
+
72
+ /**
73
+ * Handle a subtree being removed from the tree.
74
+ * If the focused node (or previous node) is within the removed subtree,
75
+ * clear the reference to prevent dead node retention and broken navigation.
76
+ */
77
+ handleSubtreeRemoved(removedRoot: TeaNode): void
78
+
79
+ /** Push a focus scope onto the stack */
80
+ enterScope(scopeId: string): void
81
+ /** Pop the current focus scope */
82
+ exitScope(): void
83
+
84
+ /** The currently active peer scope ID (WPF FocusScope model) */
85
+ readonly activeScopeId: string | null
86
+ /**
87
+ * Activate a peer focus scope. Saves current focus in the old scope's memory,
88
+ * switches to the new scope, and restores the remembered focus (or focuses
89
+ * the first focusable element in the scope subtree).
90
+ */
91
+ activateScope(scopeId: string, root: TeaNode): void
92
+
93
+ /** Get the testID path from focused node to root */
94
+ getFocusPath(root: TeaNode): string[]
95
+ /** Check if a subtree rooted at testID contains the focused node */
96
+ hasFocusWithin(root: TeaNode, testID: string): boolean
97
+
98
+ /** Focus the next focusable node in tab order */
99
+ focusNext(root: TeaNode, scope?: TeaNode): void
100
+ /** Focus the previous focusable node in tab order */
101
+ focusPrev(root: TeaNode, scope?: TeaNode): void
102
+ /** Focus in a spatial direction (up/down/left/right) */
103
+ focusDirection(
104
+ root: TeaNode,
105
+ direction: "up" | "down" | "left" | "right",
106
+ layoutFn?: (node: TeaNode) => Rect | null,
107
+ ): void
108
+
109
+ /** Subscribe for React integration (useSyncExternalStore) */
110
+ subscribe(listener: () => void): () => void
111
+ /** Get immutable snapshot for useSyncExternalStore */
112
+ getSnapshot(): FocusSnapshot
113
+ }
114
+
115
+ // ============================================================================
116
+ // Factory
117
+ // ============================================================================
118
+
119
+ export function createFocusManager(options?: FocusManagerOptions): FocusManager {
120
+ const onFocusChange = options?.onFocusChange
121
+
122
+ // Internal state
123
+ let activeElement: TeaNode | null = null
124
+ let activeId: string | null = null
125
+ let previousElement: TeaNode | null = null
126
+ let previousId: string | null = null
127
+ let focusOrigin: FocusOrigin | null = null
128
+ const scopeStack: string[] = []
129
+ const scopeMemory: Record<string, string> = {}
130
+ let activeScopeId: string | null = null
131
+
132
+ // Subscriber management
133
+ const listeners = new Set<() => void>()
134
+ let snapshot: FocusSnapshot | null = null
135
+ /** Counter incremented on every notify(); used by activateScope to detect inner notifications. */
136
+ let notifyCount = 0
137
+
138
+ function notify(): void {
139
+ snapshot = null // Invalidate cached snapshot
140
+ notifyCount++
141
+ for (const listener of listeners) {
142
+ listener()
143
+ }
144
+ }
145
+
146
+ function getTestID(node: TeaNode): string | null {
147
+ const props = node.props as Record<string, unknown>
148
+ return typeof props.testID === "string" ? props.testID : null
149
+ }
150
+
151
+ // ---- Focus operations ----
152
+
153
+ function focus(node: TeaNode, origin: FocusOrigin = "programmatic"): void {
154
+ // Skip if already focused on this node
155
+ if (activeElement === node) {
156
+ // Still update origin if different
157
+ if (focusOrigin !== origin) {
158
+ focusOrigin = origin
159
+ notify()
160
+ }
161
+ return
162
+ }
163
+
164
+ const oldElement = activeElement
165
+ previousElement = activeElement
166
+ previousId = activeId
167
+ activeElement = node
168
+ activeId = getTestID(node)
169
+ focusOrigin = origin
170
+
171
+ // Remember this focus in the current scope
172
+ if (activeId && scopeStack.length > 0) {
173
+ scopeMemory[scopeStack[scopeStack.length - 1]!] = activeId
174
+ }
175
+
176
+ notify()
177
+
178
+ // Fire focus change callback (after state is updated)
179
+ onFocusChange?.(oldElement, node, origin)
180
+ }
181
+
182
+ function focusById(id: string, root: TeaNode, origin: FocusOrigin = "programmatic"): void {
183
+ const node = findByTestID(root, id)
184
+ if (node) {
185
+ // Walk up to the nearest focusable ancestor if the found node isn't focusable
186
+ const focusable = findFocusableAncestor(node)
187
+ if (focusable) {
188
+ focus(focusable, origin)
189
+ return
190
+ }
191
+ }
192
+ // Virtual focus: set the ID without a DOM node. This enables named focus
193
+ // targets (e.g. "board-area", "detail-pane") without requiring wrapper Boxes
194
+ // that would disrupt layout.
195
+ const oldElement = activeElement
196
+ previousElement = activeElement
197
+ previousId = activeId
198
+ activeElement = null
199
+ activeId = id
200
+ focusOrigin = origin
201
+ notify()
202
+
203
+ // Fire focus change callback (old element blurs, no new node for virtual focus)
204
+ onFocusChange?.(oldElement, null, origin)
205
+ }
206
+
207
+ function blur(): void {
208
+ if (!activeElement && !activeId) return
209
+
210
+ const oldElement = activeElement
211
+ previousElement = activeElement
212
+ previousId = activeId
213
+ activeElement = null
214
+ activeId = null
215
+ focusOrigin = null
216
+
217
+ notify()
218
+
219
+ // Fire focus change callback (after state is updated)
220
+ onFocusChange?.(oldElement, null, null)
221
+ }
222
+
223
+ // ---- Subtree removal ----
224
+
225
+ /**
226
+ * Check if a node is the given target or contains it as a descendant.
227
+ */
228
+ function subtreeContains(subtreeRoot: TeaNode, target: TeaNode): boolean {
229
+ if (subtreeRoot === target) return true
230
+ for (const child of subtreeRoot.children) {
231
+ if (subtreeContains(child, target)) return true
232
+ }
233
+ return false
234
+ }
235
+
236
+ /**
237
+ * Handle a subtree being removed from the tree. If the active or previous
238
+ * element lives within the removed subtree, clear the dangling reference.
239
+ * This prevents dead node retention and broken navigation (indexOf → -1).
240
+ */
241
+ function handleSubtreeRemoved(removedRoot: TeaNode): void {
242
+ let changed = false
243
+
244
+ if (activeElement && subtreeContains(removedRoot, activeElement)) {
245
+ const oldElement = activeElement
246
+ previousElement = activeElement
247
+ previousId = activeId
248
+ activeElement = null
249
+ activeId = null
250
+ focusOrigin = null
251
+ changed = true
252
+ onFocusChange?.(oldElement, null, null)
253
+ }
254
+
255
+ if (previousElement && subtreeContains(removedRoot, previousElement)) {
256
+ previousElement = null
257
+ previousId = null
258
+ changed = true
259
+ }
260
+
261
+ if (changed) {
262
+ notify()
263
+ }
264
+ }
265
+
266
+ // ---- Scope management ----
267
+
268
+ function enterScope(scopeId: string): void {
269
+ scopeStack.push(scopeId)
270
+ notify()
271
+ }
272
+
273
+ function exitScope(): void {
274
+ const exited = scopeStack.pop()
275
+ if (exited === undefined) return
276
+
277
+ // Restore focus to the remembered element in the parent scope
278
+ // (Caller is responsible for providing root to restore if needed)
279
+ notify()
280
+ }
281
+
282
+ // ---- Peer scope activation (WPF FocusScope model) ----
283
+
284
+ function activateScope(scopeId: string, root: TeaNode): void {
285
+ // Save current focus in the outgoing scope's memory
286
+ if (activeScopeId && activeId) {
287
+ scopeMemory[activeScopeId] = activeId
288
+ }
289
+
290
+ // Switch scope
291
+ activeScopeId = scopeId
292
+
293
+ // Restore focus: remembered element, or first focusable in scope.
294
+ // Track whether notify() fired during focus/focusById to avoid double-notify.
295
+ const countBefore = notifyCount
296
+ const remembered = scopeMemory[scopeId]
297
+ if (remembered) {
298
+ focusById(remembered, root, "programmatic")
299
+ } else {
300
+ const scopeNode = findByTestID(root, scopeId)
301
+ if (scopeNode) {
302
+ const order = getTabOrder(root, scopeNode)
303
+ if (order.length > 0) {
304
+ focus(order[0]!, "programmatic")
305
+ }
306
+ }
307
+ }
308
+
309
+ // Only notify if focus/focusById didn't already notify.
310
+ if (notifyCount === countBefore) {
311
+ notify()
312
+ }
313
+ }
314
+
315
+ // ---- Tree queries ----
316
+
317
+ function getFocusPath(root: TeaNode): string[] {
318
+ if (!activeElement) return []
319
+
320
+ const path: string[] = []
321
+ let current: TeaNode | null = activeElement
322
+ while (current && current !== root.parent) {
323
+ const id = getTestID(current)
324
+ if (id) path.push(id)
325
+ current = current.parent
326
+ }
327
+ return path
328
+ }
329
+
330
+ function hasFocusWithin(root: TeaNode, testID: string): boolean {
331
+ if (!activeElement) return false
332
+
333
+ // Find the node with the given testID
334
+ const target = findByTestID(root, testID)
335
+ if (!target) return false
336
+
337
+ // Walk up from activeElement to see if we pass through target
338
+ let current: TeaNode | null = activeElement
339
+ while (current) {
340
+ if (current === target) return true
341
+ current = current.parent
342
+ }
343
+ return false
344
+ }
345
+
346
+ // ---- Navigation ----
347
+
348
+ /**
349
+ * Resolve the effective scope node for tab navigation.
350
+ * If an explicit scope is provided, use it. Otherwise, if the scopeStack
351
+ * is non-empty, find the topmost scope node in the tree by testID.
352
+ */
353
+ function resolveScope(root: TeaNode, explicitScope?: TeaNode): TeaNode | undefined {
354
+ if (explicitScope) return explicitScope
355
+
356
+ if (scopeStack.length > 0) {
357
+ const scopeId = scopeStack[scopeStack.length - 1]!
358
+ const scopeNode = findByTestID(root, scopeId)
359
+ if (scopeNode) return scopeNode
360
+ }
361
+
362
+ return undefined
363
+ }
364
+
365
+ function focusNext(root: TeaNode, scope?: TeaNode): void {
366
+ const effectiveScope = resolveScope(root, scope)
367
+ const order = getTabOrder(root, effectiveScope)
368
+ if (order.length === 0) return
369
+
370
+ if (!activeElement) {
371
+ // Nothing focused — focus the first element
372
+ focus(order[0]!, "keyboard")
373
+ return
374
+ }
375
+
376
+ const currentIndex = order.indexOf(activeElement)
377
+ // Wrap around to the first element
378
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % order.length
379
+ focus(order[nextIndex]!, "keyboard")
380
+ }
381
+
382
+ function focusPrev(root: TeaNode, scope?: TeaNode): void {
383
+ const effectiveScope = resolveScope(root, scope)
384
+ const order = getTabOrder(root, effectiveScope)
385
+ if (order.length === 0) return
386
+
387
+ if (!activeElement) {
388
+ // Nothing focused — focus the last element
389
+ focus(order[order.length - 1]!, "keyboard")
390
+ return
391
+ }
392
+
393
+ const currentIndex = order.indexOf(activeElement)
394
+ // Wrap around to the last element
395
+ const prevIndex = currentIndex <= 0 ? order.length - 1 : currentIndex - 1
396
+ focus(order[prevIndex]!, "keyboard")
397
+ }
398
+
399
+ function focusDirection(
400
+ root: TeaNode,
401
+ direction: "up" | "down" | "left" | "right",
402
+ layoutFn?: (node: TeaNode) => Rect | null,
403
+ ): void {
404
+ if (!activeElement) return
405
+
406
+ // Check for explicit focus link first
407
+ const explicitTarget = getExplicitFocusLink(activeElement, direction)
408
+ if (explicitTarget) {
409
+ focusById(explicitTarget, root, "keyboard")
410
+ return
411
+ }
412
+
413
+ // Fall back to spatial navigation
414
+ const candidates = getTabOrder(root)
415
+ const resolvedLayoutFn = layoutFn ?? ((node: TeaNode) => node.screenRect)
416
+ const target = findSpatialTarget(activeElement, direction, candidates, resolvedLayoutFn)
417
+ if (target) {
418
+ focus(target, "keyboard")
419
+ }
420
+ }
421
+
422
+ // ---- Subscribe/snapshot for useSyncExternalStore ----
423
+
424
+ function subscribe(listener: () => void): () => void {
425
+ listeners.add(listener)
426
+ return () => {
427
+ listeners.delete(listener)
428
+ }
429
+ }
430
+
431
+ function getSnapshot(): FocusSnapshot {
432
+ if (!snapshot) {
433
+ snapshot = {
434
+ activeId,
435
+ previousId,
436
+ focusOrigin,
437
+ scopeStack: [...scopeStack],
438
+ activeScopeId,
439
+ }
440
+ }
441
+ return snapshot
442
+ }
443
+
444
+ // ---- Public interface ----
445
+
446
+ return {
447
+ get activeElement() {
448
+ return activeElement
449
+ },
450
+ get activeId() {
451
+ return activeId
452
+ },
453
+ get previousElement() {
454
+ return previousElement
455
+ },
456
+ get previousId() {
457
+ return previousId
458
+ },
459
+ get focusOrigin() {
460
+ return focusOrigin
461
+ },
462
+ get scopeStack() {
463
+ return [...scopeStack] as readonly string[]
464
+ },
465
+ get scopeMemory() {
466
+ return scopeMemory as Readonly<Record<string, string>>
467
+ },
468
+ get activeScopeId() {
469
+ return activeScopeId
470
+ },
471
+
472
+ focus,
473
+ focusById,
474
+ blur,
475
+ handleSubtreeRemoved,
476
+
477
+ enterScope,
478
+ exitScope,
479
+ activateScope,
480
+
481
+ getFocusPath,
482
+ hasFocusWithin,
483
+
484
+ focusNext,
485
+ focusPrev,
486
+ focusDirection,
487
+
488
+ subscribe,
489
+ getSnapshot,
490
+ }
491
+ }