@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,243 @@
1
+ /**
2
+ * DOM-level Focus and Keyboard Events for silvery
3
+ *
4
+ * Provides React DOM-compatible focus/keyboard event infrastructure:
5
+ * - SilveryKeyEvent / SilveryFocusEvent synthetic event objects
6
+ * - Event dispatch with capture/target/bubble phases (key events)
7
+ * - Event dispatch with target + bubble (focus events)
8
+ *
9
+ * Follows the same patterns as mouse-events.ts for consistency.
10
+ */
11
+
12
+ import type { Key } from "./keys"
13
+ import { getAncestorPath } from "./tree-utils.js"
14
+ import type { TeaNode } from "./types"
15
+
16
+ // ============================================================================
17
+ // Event Types
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Synthetic keyboard event, mirroring React.KeyboardEvent / DOM KeyboardEvent.
22
+ */
23
+ export interface SilveryKeyEvent {
24
+ /** The printable character, or "" for non-printable keys */
25
+ key: string
26
+ /** Raw terminal input string */
27
+ input: string
28
+ /** Modifier keys */
29
+ ctrl: boolean
30
+ meta: boolean
31
+ shift: boolean
32
+ super: boolean
33
+ hyper: boolean
34
+ /** Kitty event type */
35
+ eventType?: "press" | "repeat" | "release"
36
+ /** Deepest focusable node that received this event */
37
+ target: TeaNode
38
+ /** Node whose handler is currently firing (changes during capture/bubble) */
39
+ currentTarget: TeaNode
40
+ /** Stop event from propagating further */
41
+ stopPropagation(): void
42
+ /** Prevent default behavior */
43
+ preventDefault(): void
44
+ /** Whether stopPropagation() was called */
45
+ readonly propagationStopped: boolean
46
+ /** Whether preventDefault() was called */
47
+ readonly defaultPrevented: boolean
48
+ /** Raw parsed key data */
49
+ nativeEvent: { input: string; key: Key }
50
+ }
51
+
52
+ /**
53
+ * Synthetic focus event, mirroring React.FocusEvent / DOM FocusEvent.
54
+ */
55
+ export interface SilveryFocusEvent {
56
+ /** The node gaining or losing focus */
57
+ target: TeaNode
58
+ /** The other node involved (losing focus on 'focus', gaining on 'blur') */
59
+ relatedTarget: TeaNode | null
60
+ /** Event type */
61
+ type: "focus" | "blur"
62
+ /** Node whose handler is currently firing (changes during bubble) */
63
+ currentTarget: TeaNode
64
+ /** Stop event from bubbling to parent nodes */
65
+ stopPropagation(): void
66
+ /** Whether stopPropagation() was called */
67
+ readonly propagationStopped: boolean
68
+ }
69
+
70
+ // ============================================================================
71
+ // Focus Event Handler Props (added to BoxProps)
72
+ // ============================================================================
73
+
74
+ export interface FocusEventProps {
75
+ /** Whether this node can receive focus */
76
+ focusable?: boolean
77
+ /** Whether this node should receive focus on mount */
78
+ autoFocus?: boolean
79
+ /** Whether this node creates a focus scope (focus trapping boundary) */
80
+ focusScope?: boolean
81
+ /** ID of the node to focus when pressing Up from this node */
82
+ nextFocusUp?: string
83
+ /** ID of the node to focus when pressing Down from this node */
84
+ nextFocusDown?: string
85
+ /** ID of the node to focus when pressing Left from this node */
86
+ nextFocusLeft?: string
87
+ /** ID of the node to focus when pressing Right from this node */
88
+ nextFocusRight?: string
89
+ /** Called when this node gains focus */
90
+ onFocus?: (event: SilveryFocusEvent) => void
91
+ /** Called when this node loses focus */
92
+ onBlur?: (event: SilveryFocusEvent) => void
93
+ /** Called on key down (bubble phase) */
94
+ onKeyDown?: (event: SilveryKeyEvent, dispatch?: (msg: unknown) => void) => void
95
+ /** Called on key up (bubble phase) */
96
+ onKeyUp?: (event: SilveryKeyEvent, dispatch?: (msg: unknown) => void) => void
97
+ /** Called on key down (capture phase — fires before target) */
98
+ onKeyDownCapture?: (event: SilveryKeyEvent) => void
99
+ }
100
+
101
+ // ============================================================================
102
+ // Event Factories
103
+ // ============================================================================
104
+
105
+ /**
106
+ * Create a synthetic keyboard event.
107
+ */
108
+ export function createKeyEvent(input: string, key: Key, target: TeaNode): SilveryKeyEvent {
109
+ let propagationStopped = false
110
+ let defaultPrevented = false
111
+
112
+ return {
113
+ key: input,
114
+ input,
115
+ ctrl: key.ctrl,
116
+ meta: key.meta,
117
+ shift: key.shift,
118
+ super: key.super,
119
+ hyper: key.hyper,
120
+ eventType: key.eventType,
121
+ target,
122
+ currentTarget: target,
123
+ nativeEvent: { input, key },
124
+ get propagationStopped() {
125
+ return propagationStopped
126
+ },
127
+ get defaultPrevented() {
128
+ return defaultPrevented
129
+ },
130
+ stopPropagation() {
131
+ propagationStopped = true
132
+ },
133
+ preventDefault() {
134
+ defaultPrevented = true
135
+ },
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Create a synthetic focus event.
141
+ */
142
+ export function createFocusEvent(
143
+ type: "focus" | "blur",
144
+ target: TeaNode,
145
+ relatedTarget: TeaNode | null,
146
+ ): SilveryFocusEvent {
147
+ let propagationStopped = false
148
+
149
+ return {
150
+ type,
151
+ target,
152
+ relatedTarget,
153
+ currentTarget: target,
154
+ get propagationStopped() {
155
+ return propagationStopped
156
+ },
157
+ stopPropagation() {
158
+ propagationStopped = true
159
+ },
160
+ }
161
+ }
162
+
163
+ // ============================================================================
164
+ // Tree Walking
165
+ // ============================================================================
166
+
167
+ // ============================================================================
168
+ // Event Dispatch
169
+ // ============================================================================
170
+
171
+ /**
172
+ * Dispatch a keyboard event through the render tree with DOM-style
173
+ * capture/target/bubble phases.
174
+ *
175
+ * 1. Capture phase: root → target (onKeyDownCapture props)
176
+ * 2. Target phase: target's onKeyDown
177
+ * 3. Bubble phase: target parent → root (onKeyDown props)
178
+ *
179
+ * stopPropagation() halts traversal at any phase.
180
+ */
181
+ export function dispatchKeyEvent(event: SilveryKeyEvent, dispatch?: (msg: unknown) => void): void {
182
+ const path = getAncestorPath(event.target)
183
+ const mutableEvent = event as { currentTarget: TeaNode }
184
+
185
+ // Capture phase: root → target (reversed path, excluding target)
186
+ for (let i = path.length - 1; i > 0; i--) {
187
+ if (event.propagationStopped) return
188
+ const node = path[i]!
189
+ const handler = (node.props as Record<string, unknown>).onKeyDownCapture as
190
+ | ((e: SilveryKeyEvent) => void)
191
+ | undefined
192
+ if (handler) {
193
+ mutableEvent.currentTarget = node
194
+ handler(event)
195
+ }
196
+ }
197
+
198
+ // Target phase: fire onKeyDown on the target itself
199
+ if (!event.propagationStopped) {
200
+ const target = path[0]!
201
+ mutableEvent.currentTarget = target
202
+ const handler = (target.props as Record<string, unknown>).onKeyDown as
203
+ | ((e: SilveryKeyEvent, d?: (msg: unknown) => void) => void)
204
+ | undefined
205
+ if (handler) {
206
+ handler(event, dispatch)
207
+ }
208
+ }
209
+
210
+ // Bubble phase: target parent → root
211
+ for (let i = 1; i < path.length; i++) {
212
+ if (event.propagationStopped) return
213
+ const node = path[i]!
214
+ const handler = (node.props as Record<string, unknown>).onKeyDown as
215
+ | ((e: SilveryKeyEvent, d?: (msg: unknown) => void) => void)
216
+ | undefined
217
+ if (handler) {
218
+ mutableEvent.currentTarget = node
219
+ handler(event, dispatch)
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Dispatch a focus event through the render tree.
226
+ *
227
+ * Fires onFocus/onBlur on the target, then bubbles to ancestors.
228
+ */
229
+ export function dispatchFocusEvent(event: SilveryFocusEvent): void {
230
+ const handlerProp = event.type === "focus" ? "onFocus" : "onBlur"
231
+ const path = getAncestorPath(event.target)
232
+ const mutableEvent = event as { currentTarget: TeaNode }
233
+
234
+ for (const node of path) {
235
+ if (event.propagationStopped) break
236
+
237
+ const handler = (node.props as Record<string, unknown>)[handlerProp] as ((e: SilveryFocusEvent) => void) | undefined
238
+ if (handler) {
239
+ mutableEvent.currentTarget = node
240
+ handler(event)
241
+ }
242
+ }
243
+ }