funicular 0.0.1 → 0.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
data/mrblib/component.rb
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Component
|
|
3
|
+
class StateAccessor
|
|
4
|
+
def initialize(state_hash)
|
|
5
|
+
@state = state_hash
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Support dynamic key access: state[:key] or state[variable_key]
|
|
9
|
+
def [](key)
|
|
10
|
+
@state[key]
|
|
11
|
+
end
|
|
12
|
+
def method_missing(method, *args)
|
|
13
|
+
if method.to_s.end_with?('=')
|
|
14
|
+
key = method.to_s[0..-2] || method
|
|
15
|
+
raise "Use patch(#{key}: value) to update state"
|
|
16
|
+
else
|
|
17
|
+
@state[method]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def respond_to_missing?(method, include_private = false)
|
|
22
|
+
return false if method.to_s.end_with?('=')
|
|
23
|
+
@state.key?(method) || super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_accessor :props, :vdom, :dom_element, :mounted
|
|
28
|
+
attr_reader :refs
|
|
29
|
+
|
|
30
|
+
def initialize(props = {})
|
|
31
|
+
@props = props
|
|
32
|
+
@state = initialize_state || {}
|
|
33
|
+
@state_accessor = nil
|
|
34
|
+
@style_accessor = nil
|
|
35
|
+
@vdom = nil
|
|
36
|
+
@dom_element = nil
|
|
37
|
+
@refs = {}
|
|
38
|
+
@event_listeners = []
|
|
39
|
+
@mounted = false
|
|
40
|
+
@updating = false
|
|
41
|
+
@child_components = []
|
|
42
|
+
|
|
43
|
+
# Initialize suspense state
|
|
44
|
+
@suspense_data = {}
|
|
45
|
+
@suspense_states = {} # :pending, :loading, :resolved, :rejected
|
|
46
|
+
@suspense_errors = {}
|
|
47
|
+
@suspense_pending_timers = [] # Track pending setTimeout IDs for cleanup
|
|
48
|
+
self.class.suspense_definitions.each_key do |name|
|
|
49
|
+
@suspense_states[name] = :pending
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Register component for debugging in development mode
|
|
53
|
+
@__debug_id__ = Funicular::Debug.register_component(self) if Funicular::Debug.enabled?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def state
|
|
57
|
+
@state_accessor ||= StateAccessor.new(@state)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Override this method in subclasses to define initial state
|
|
61
|
+
def initialize_state
|
|
62
|
+
{}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Load all registered suspense data
|
|
66
|
+
# Called automatically in component_mounted if suspense definitions exist
|
|
67
|
+
def load_suspense_data
|
|
68
|
+
self.class.suspense_definitions.each do |name, loader|
|
|
69
|
+
load_single_suspense(name, loader)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Load a single suspense data by name
|
|
74
|
+
def load_single_suspense(name, definition = nil)
|
|
75
|
+
definition ||= self.class.suspense_definitions[name]
|
|
76
|
+
return unless definition
|
|
77
|
+
return if @suspense_states[name] == :loading
|
|
78
|
+
|
|
79
|
+
# Support both old format (just loader) and new format (hash with loader and on_resolve)
|
|
80
|
+
if definition.is_a?(Hash)
|
|
81
|
+
loader = definition[:loader]
|
|
82
|
+
on_resolve = definition[:on_resolve]
|
|
83
|
+
min_delay = definition[:min_delay]
|
|
84
|
+
else
|
|
85
|
+
loader = definition
|
|
86
|
+
on_resolve = nil
|
|
87
|
+
min_delay = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@suspense_states[name] = :loading
|
|
91
|
+
start_time = Time.now.to_f * 1000 # Convert to milliseconds
|
|
92
|
+
|
|
93
|
+
# Helper to finalize resolve
|
|
94
|
+
do_resolve = ->(data) {
|
|
95
|
+
@suspense_data[name] = data
|
|
96
|
+
@suspense_states[name] = :resolved
|
|
97
|
+
@suspense_errors[name] = nil
|
|
98
|
+
if on_resolve
|
|
99
|
+
# on_resolve callback is expected to call patch() which triggers re-render
|
|
100
|
+
instance_exec(data, &on_resolve)
|
|
101
|
+
else
|
|
102
|
+
re_render if @mounted
|
|
103
|
+
end
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
resolve = ->(data) {
|
|
107
|
+
if min_delay
|
|
108
|
+
elapsed = (Time.now.to_f * 1000) - start_time
|
|
109
|
+
remaining = min_delay - elapsed
|
|
110
|
+
if remaining > 0
|
|
111
|
+
# Delay resolve to ensure minimum loading time
|
|
112
|
+
timer_id = JS.global.setTimeout(remaining.to_i) do
|
|
113
|
+
do_resolve.call(data) if @mounted
|
|
114
|
+
end
|
|
115
|
+
@suspense_pending_timers << timer_id
|
|
116
|
+
else
|
|
117
|
+
do_resolve.call(data)
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
do_resolve.call(data)
|
|
121
|
+
end
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
reject = ->(error) {
|
|
125
|
+
@suspense_data[name] = nil
|
|
126
|
+
@suspense_states[name] = :rejected
|
|
127
|
+
@suspense_errors[name] = error
|
|
128
|
+
re_render if @mounted
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Execute loader with resolve/reject callbacks
|
|
132
|
+
instance_exec(resolve, reject, &loader)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Reload suspense data (useful for retry or refresh)
|
|
136
|
+
def reload_suspense(name)
|
|
137
|
+
@suspense_states[name] = :pending
|
|
138
|
+
load_single_suspense(name)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if suspense data is loading
|
|
142
|
+
def suspense_loading?(*names)
|
|
143
|
+
names = self.class.suspense_definitions.keys if names.empty?
|
|
144
|
+
names.any? { |name| @suspense_states[name] == :pending || @suspense_states[name] == :loading }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Check if suspense data has error
|
|
148
|
+
def suspense_error?(name)
|
|
149
|
+
@suspense_states[name] == :rejected
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get suspense error
|
|
153
|
+
def suspense_error(name)
|
|
154
|
+
@suspense_errors[name]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Suspense helper for render method
|
|
158
|
+
#
|
|
159
|
+
# @param fallback [Proc] Content to show while loading
|
|
160
|
+
# @param error [Proc] Optional content to show on error (receives error as argument)
|
|
161
|
+
# @yield Block to render when data is loaded
|
|
162
|
+
#
|
|
163
|
+
# @example
|
|
164
|
+
# suspense(fallback: -> { div { "Loading..." } }) do
|
|
165
|
+
# div { user.name }
|
|
166
|
+
# end
|
|
167
|
+
#
|
|
168
|
+
# @example with error handling
|
|
169
|
+
# suspense(
|
|
170
|
+
# fallback: -> { div { "Loading..." } },
|
|
171
|
+
# error: ->(e) { div { "Error: #{e}" } }
|
|
172
|
+
# ) do
|
|
173
|
+
# div { user.name }
|
|
174
|
+
# end
|
|
175
|
+
def suspense(fallback:, error: nil, &block)
|
|
176
|
+
# Check for any rejected suspense
|
|
177
|
+
rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
|
|
178
|
+
if rejected_name
|
|
179
|
+
if error
|
|
180
|
+
error.call(@suspense_errors[rejected_name])
|
|
181
|
+
else
|
|
182
|
+
fallback.call
|
|
183
|
+
end
|
|
184
|
+
elsif suspense_loading?
|
|
185
|
+
# Check if any suspense is still loading
|
|
186
|
+
# Note: Loading is started in mount(), not here
|
|
187
|
+
fallback.call
|
|
188
|
+
else
|
|
189
|
+
# All suspense data loaded, render content
|
|
190
|
+
block.call
|
|
191
|
+
end
|
|
192
|
+
# Note: We don't add result to @current_children because
|
|
193
|
+
# the DSL methods inside fallback/block already do that
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Class methods for styles DSL
|
|
197
|
+
def self.styles(&block)
|
|
198
|
+
builder = StyleBuilder.new
|
|
199
|
+
builder.instance_eval(&block)
|
|
200
|
+
@styles_definitions = builder.to_definitions
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.styles_definitions
|
|
204
|
+
@styles_definitions ||= {}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Suspense DSL - register async data loaders
|
|
208
|
+
#
|
|
209
|
+
# @param name [Symbol] Name of the suspense data (becomes accessible as method)
|
|
210
|
+
# @param loader [Proc] Lambda that receives resolve and reject callbacks
|
|
211
|
+
# @param on_resolve [Proc] Optional callback called with data after resolve, before re-render
|
|
212
|
+
# @param min_delay [Integer] Minimum time in ms to show loading state (prevents flickering)
|
|
213
|
+
#
|
|
214
|
+
# @example
|
|
215
|
+
# use_suspense :user, ->(resolve, reject) {
|
|
216
|
+
# User.find(props[:id]) do |user, error|
|
|
217
|
+
# error ? reject.call(error) : resolve.call(user)
|
|
218
|
+
# end
|
|
219
|
+
# }
|
|
220
|
+
#
|
|
221
|
+
# @example with on_resolve callback and min_delay
|
|
222
|
+
# use_suspense :current_user,
|
|
223
|
+
# ->(resolve, reject) { Session.current_user { |u, e| ... } },
|
|
224
|
+
# on_resolve: ->(user) { patch(user: { username: user.username }) },
|
|
225
|
+
# min_delay: 300 # Show loading spinner for at least 300ms
|
|
226
|
+
def self.use_suspense(name, loader, on_resolve: nil, min_delay: nil)
|
|
227
|
+
@suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
|
|
228
|
+
@suspense_definitions[name] = { loader: loader, on_resolve: on_resolve, min_delay: min_delay }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.suspense_definitions
|
|
232
|
+
@suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Instance method to access styles
|
|
236
|
+
def s
|
|
237
|
+
@style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Update state and trigger re-render
|
|
241
|
+
def patch(new_state)
|
|
242
|
+
return unless @mounted
|
|
243
|
+
return if @updating
|
|
244
|
+
|
|
245
|
+
begin
|
|
246
|
+
@updating = true
|
|
247
|
+
component_will_update if respond_to?(:component_will_update)
|
|
248
|
+
|
|
249
|
+
# Convert JS::Object values to Ruby native types automatically
|
|
250
|
+
normalized_state = {} #: Hash[Symbol, untyped]
|
|
251
|
+
new_state.each do |key, value|
|
|
252
|
+
normalized_state[key] = normalize_state_value(value)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
@state = @state.merge(normalized_state)
|
|
256
|
+
@state_accessor = nil # Invalidate accessor to reflect new state
|
|
257
|
+
re_render
|
|
258
|
+
|
|
259
|
+
component_updated if respond_to?(:component_updated)
|
|
260
|
+
rescue => e
|
|
261
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
262
|
+
raise e
|
|
263
|
+
ensure
|
|
264
|
+
@updating = false
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Mount component to a DOM container
|
|
269
|
+
def mount(container)
|
|
270
|
+
return if @mounted
|
|
271
|
+
|
|
272
|
+
begin
|
|
273
|
+
component_will_mount if respond_to?(:component_will_mount)
|
|
274
|
+
|
|
275
|
+
@container = container
|
|
276
|
+
new_vdom = build_vdom
|
|
277
|
+
@dom_element = VDOM::Renderer.new.render(new_vdom)
|
|
278
|
+
bind_events(@dom_element, new_vdom)
|
|
279
|
+
collect_refs(@dom_element, new_vdom)
|
|
280
|
+
collect_child_components(new_vdom)
|
|
281
|
+
container.appendChild(@dom_element)
|
|
282
|
+
@vdom = new_vdom
|
|
283
|
+
@mounted = true
|
|
284
|
+
|
|
285
|
+
# Start loading suspense data after mounted
|
|
286
|
+
load_suspense_data if self.class.suspense_definitions.any?
|
|
287
|
+
|
|
288
|
+
# Mark child components as mounted and call their lifecycle hooks
|
|
289
|
+
@child_components.each do |child|
|
|
290
|
+
child.mounted = true
|
|
291
|
+
child.component_mounted if child.respond_to?(:component_mounted)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
component_mounted if respond_to?(:component_mounted)
|
|
295
|
+
rescue => e
|
|
296
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
297
|
+
raise e
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Unmount component from DOM
|
|
302
|
+
def unmount
|
|
303
|
+
return unless @mounted
|
|
304
|
+
# puts "==> Unmounting: #{self.class} id: #{@__debug_id__}"
|
|
305
|
+
|
|
306
|
+
begin
|
|
307
|
+
component_will_unmount if respond_to?(:component_will_unmount)
|
|
308
|
+
|
|
309
|
+
# Unmount child components first
|
|
310
|
+
# puts " > Unmounting children: #{@child_components.map(&:class).join(', ')}"
|
|
311
|
+
@child_components.each do |child|
|
|
312
|
+
child.unmount if child.respond_to?(:unmount)
|
|
313
|
+
end
|
|
314
|
+
@child_components = []
|
|
315
|
+
|
|
316
|
+
cleanup_events
|
|
317
|
+
cleanup_suspense_timers
|
|
318
|
+
@container.removeChild(@dom_element) if @container && @dom_element
|
|
319
|
+
@mounted = false
|
|
320
|
+
@dom_element = nil
|
|
321
|
+
@vdom = nil
|
|
322
|
+
@refs = {}
|
|
323
|
+
|
|
324
|
+
# Unregister component from debugging in development mode
|
|
325
|
+
Funicular::Debug.unregister_component(@__debug_id__) if @__debug_id__
|
|
326
|
+
|
|
327
|
+
component_unmounted if respond_to?(:component_unmounted)
|
|
328
|
+
rescue => e
|
|
329
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
330
|
+
raise e
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Override this method in subclasses to define render logic
|
|
335
|
+
def render
|
|
336
|
+
raise "Subclasses must implement the render method"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Build VDOM tree from render method
|
|
340
|
+
# Called by VDOM::Renderer, Differ, and Patcher
|
|
341
|
+
def build_vdom
|
|
342
|
+
@rendering = true
|
|
343
|
+
@current_children = nil
|
|
344
|
+
result = render
|
|
345
|
+
@rendering = false
|
|
346
|
+
|
|
347
|
+
# Convert render result to VNode
|
|
348
|
+
vnode = normalize_vnode(result)
|
|
349
|
+
|
|
350
|
+
# Add data-component attribute to the root element
|
|
351
|
+
if Funicular.env.development? && vnode
|
|
352
|
+
add_data_component_attribute(vnode)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
vnode
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Bind event handlers to DOM elements
|
|
359
|
+
# Called by VDOM::Renderer and Patcher
|
|
360
|
+
def bind_events(dom_element, vnode)
|
|
361
|
+
# Skip Component vnodes - they manage their own events
|
|
362
|
+
return if vnode.is_a?(VDOM::Component)
|
|
363
|
+
return unless vnode.is_a?(VDOM::Element)
|
|
364
|
+
|
|
365
|
+
event_types = [] #: Array[String]
|
|
366
|
+
|
|
367
|
+
vnode.props.each do |key, value|
|
|
368
|
+
key_str = key.to_s
|
|
369
|
+
next unless key_str.start_with?('on')
|
|
370
|
+
|
|
371
|
+
event_name = key_str[2..-1]&.downcase || ""
|
|
372
|
+
event_types << event_name
|
|
373
|
+
|
|
374
|
+
# addEventListener expects a block, not a Proc
|
|
375
|
+
callback_id = case value
|
|
376
|
+
when Symbol
|
|
377
|
+
result = dom_element.addEventListener(event_name) do |event|
|
|
378
|
+
begin
|
|
379
|
+
self.send(value, event)
|
|
380
|
+
rescue => e
|
|
381
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
382
|
+
raise e
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
result
|
|
386
|
+
when Method
|
|
387
|
+
result = dom_element.addEventListener(event_name) do |event|
|
|
388
|
+
begin
|
|
389
|
+
# @type var value: Method
|
|
390
|
+
# Check if Method expects arguments (arity)
|
|
391
|
+
if value.arity == 0
|
|
392
|
+
value.call
|
|
393
|
+
else
|
|
394
|
+
value.call(event)
|
|
395
|
+
end
|
|
396
|
+
rescue => e
|
|
397
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
398
|
+
raise e
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
result
|
|
402
|
+
when Proc
|
|
403
|
+
result = dom_element.addEventListener(event_name) do |event|
|
|
404
|
+
begin
|
|
405
|
+
# @type var value: Proc
|
|
406
|
+
# Check if Proc expects arguments (arity)
|
|
407
|
+
if value.arity == 0
|
|
408
|
+
value.call
|
|
409
|
+
else
|
|
410
|
+
value.call(event)
|
|
411
|
+
end
|
|
412
|
+
rescue => e
|
|
413
|
+
component_raised(e) if respond_to?(:component_raised)
|
|
414
|
+
raise e
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
result
|
|
418
|
+
else
|
|
419
|
+
raise "Invalid event handler: #{value.class}. Must be Symbol, Method, or Proc."
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
@event_listeners << callback_id
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Add debug attribute for DevTools
|
|
426
|
+
if Funicular::Debug.enabled? && event_types.length > 0
|
|
427
|
+
dom_element.setAttribute("data-event-listeners", event_types.join(","))
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Recursively bind events for children
|
|
431
|
+
if vnode.children && dom_element.children
|
|
432
|
+
children = dom_element.children.to_a
|
|
433
|
+
vnode.children.each_with_index do |child_vnode, index|
|
|
434
|
+
if child_vnode.is_a?(VDOM::Element)
|
|
435
|
+
child_element = children[index]
|
|
436
|
+
bind_events(child_element, child_vnode) if child_element
|
|
437
|
+
elsif child_vnode.is_a?(VDOM::Component)
|
|
438
|
+
# Component vnodes handle their own events in render_component
|
|
439
|
+
# Skip them here to avoid duplicate event binding
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Collect ref elements from VDOM
|
|
446
|
+
# Called by VDOM::Renderer and Patcher
|
|
447
|
+
def collect_refs(dom_element, vnode, refs_map = {})
|
|
448
|
+
# Skip Component vnodes - they manage their own refs
|
|
449
|
+
return refs_map if vnode.is_a?(VDOM::Component)
|
|
450
|
+
return refs_map unless vnode.is_a?(VDOM::Element)
|
|
451
|
+
|
|
452
|
+
if vnode.props[:ref]
|
|
453
|
+
ref_name = vnode.props[:ref].to_sym
|
|
454
|
+
@refs[ref_name] = dom_element
|
|
455
|
+
refs_map[ref_name] = dom_element
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
if vnode.children && dom_element.children
|
|
459
|
+
children = dom_element.children.to_a
|
|
460
|
+
vnode.children.each_with_index do |child_vnode, index|
|
|
461
|
+
if child_vnode.is_a?(VDOM::Element)
|
|
462
|
+
child_element = children[index]
|
|
463
|
+
collect_refs(child_element, child_vnode, refs_map) if child_element
|
|
464
|
+
elsif child_vnode.is_a?(VDOM::Component)
|
|
465
|
+
# Component vnodes handle their own refs in render_component
|
|
466
|
+
# Skip them here to avoid duplicate processing
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
refs_map
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Cleanup event listeners
|
|
475
|
+
# Called by VDOM::Patcher
|
|
476
|
+
def cleanup_events
|
|
477
|
+
@event_listeners.each do |callback_id|
|
|
478
|
+
JS::Object.removeEventListener(callback_id)
|
|
479
|
+
end
|
|
480
|
+
@event_listeners = []
|
|
481
|
+
|
|
482
|
+
# NOTE: Do NOT cleanup child component events here!
|
|
483
|
+
# Child components manage their own events and will cleanup
|
|
484
|
+
# when they themselves re-render or unmount
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Cleanup pending suspense timers
|
|
488
|
+
def cleanup_suspense_timers
|
|
489
|
+
return unless @suspense_pending_timers
|
|
490
|
+
@suspense_pending_timers.each do |timer_id|
|
|
491
|
+
JS.global.clearTimeout(timer_id)
|
|
492
|
+
end
|
|
493
|
+
@suspense_pending_timers = []
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
private
|
|
497
|
+
|
|
498
|
+
# Normalize state value by converting JS::Object to Ruby native types
|
|
499
|
+
def normalize_state_value(value)
|
|
500
|
+
if value.is_a?(Hash)
|
|
501
|
+
# Recursively normalize hash values
|
|
502
|
+
normalized = {} #: Hash[untyped, untyped]
|
|
503
|
+
value.each do |k, v|
|
|
504
|
+
normalized[k] = normalize_state_value(v)
|
|
505
|
+
end
|
|
506
|
+
normalized
|
|
507
|
+
elsif value.is_a?(Array)
|
|
508
|
+
# Recursively normalize array elements
|
|
509
|
+
value.map { |v| normalize_state_value(v) }
|
|
510
|
+
elsif value.is_a?(JS::Object)
|
|
511
|
+
# Convert JS::Object to appropriate Ruby type
|
|
512
|
+
case value.type
|
|
513
|
+
when :string
|
|
514
|
+
value.to_s
|
|
515
|
+
when :number
|
|
516
|
+
# Check if it's an integer or float
|
|
517
|
+
num = value.to_f
|
|
518
|
+
num == num.to_i ? num.to_i : num
|
|
519
|
+
when :boolean
|
|
520
|
+
# JS::Object#== now supports direct comparison with Ruby true/false
|
|
521
|
+
value == true
|
|
522
|
+
when :null, :undefined
|
|
523
|
+
nil
|
|
524
|
+
when :array
|
|
525
|
+
value.to_a.map { |v| normalize_state_value(v) }
|
|
526
|
+
when :object
|
|
527
|
+
# For plain objects, keep as JS::Object or convert to hash if needed
|
|
528
|
+
value
|
|
529
|
+
else
|
|
530
|
+
value
|
|
531
|
+
end
|
|
532
|
+
else
|
|
533
|
+
# Return as-is for Ruby native types
|
|
534
|
+
value
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Re-render component (called by update)
|
|
539
|
+
def re_render
|
|
540
|
+
return unless @mounted
|
|
541
|
+
|
|
542
|
+
new_vdom = build_vdom
|
|
543
|
+
patches = VDOM::Differ.diff(@vdom, new_vdom)
|
|
544
|
+
|
|
545
|
+
# Always cleanup and rebind events to avoid stale event listeners
|
|
546
|
+
cleanup_events
|
|
547
|
+
|
|
548
|
+
unless patches.empty?
|
|
549
|
+
@dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
bind_events(@dom_element, new_vdom)
|
|
553
|
+
collect_refs(@dom_element, new_vdom)
|
|
554
|
+
collect_child_components(new_vdom)
|
|
555
|
+
|
|
556
|
+
# Mark child components as mounted and call their lifecycle hooks
|
|
557
|
+
@child_components.each do |child|
|
|
558
|
+
unless child.mounted
|
|
559
|
+
child.mounted = true
|
|
560
|
+
child.component_mounted if child.respond_to?(:component_mounted)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
@vdom = new_vdom
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Add data-component attribute to the root element
|
|
568
|
+
def add_data_component_attribute(vnode)
|
|
569
|
+
return unless vnode.is_a?(VDOM::Element)
|
|
570
|
+
vnode.props[:'data-component'] = self.class.to_s
|
|
571
|
+
vnode.props[:'data-component-id'] = @__debug_id__.to_s if @__debug_id__
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Normalize render result to VNode
|
|
575
|
+
def normalize_vnode(value)
|
|
576
|
+
case value
|
|
577
|
+
when VDOM::VNode
|
|
578
|
+
_ = value
|
|
579
|
+
when String
|
|
580
|
+
VDOM::Text.new(value)
|
|
581
|
+
when Integer, Float
|
|
582
|
+
VDOM::Text.new(value.to_s)
|
|
583
|
+
when Array
|
|
584
|
+
# Arrays are typically return values from iterators like .each or .map
|
|
585
|
+
# The elements have already been added to @current_children during iteration
|
|
586
|
+
# Return nil to avoid duplicate rendering
|
|
587
|
+
nil
|
|
588
|
+
when nil
|
|
589
|
+
VDOM::Text.new("")
|
|
590
|
+
when Class
|
|
591
|
+
# If it's a component class, create a component VNode
|
|
592
|
+
if value.ancestors.include?(Funicular::Component)
|
|
593
|
+
VDOM::Component.new(value, {})
|
|
594
|
+
else
|
|
595
|
+
VDOM::Text.new(value.to_s)
|
|
596
|
+
end
|
|
597
|
+
else
|
|
598
|
+
VDOM::Text.new(value.to_s)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Collect child component instances from VDOM tree
|
|
603
|
+
def collect_child_components(vnode)
|
|
604
|
+
@child_components = []
|
|
605
|
+
collect_child_components_recursive(vnode, @child_components)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def collect_child_components_recursive(vnode, components)
|
|
609
|
+
if vnode.is_a?(VDOM::Component)
|
|
610
|
+
components << vnode.instance if vnode.instance
|
|
611
|
+
# Recursively collect from child component's vdom
|
|
612
|
+
if vnode.instance && vnode.instance.vdom
|
|
613
|
+
collect_child_components_recursive(vnode.instance.vdom, components)
|
|
614
|
+
end
|
|
615
|
+
elsif vnode.is_a?(VDOM::Element)
|
|
616
|
+
vnode.children&.each do |child|
|
|
617
|
+
# @type var child: VDOM::VNode
|
|
618
|
+
collect_child_components_recursive(child, components)
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# DSL methods for HTML elements
|
|
624
|
+
HTML_TAGS = %w[
|
|
625
|
+
div span p a
|
|
626
|
+
h1 h2 h3 h4 h5 h6
|
|
627
|
+
ul ol li
|
|
628
|
+
table thead tbody tr th td
|
|
629
|
+
form input textarea button select option label
|
|
630
|
+
header footer nav section article aside
|
|
631
|
+
img video audio canvas
|
|
632
|
+
br hr
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
HTML_TAGS.each do |tag|
|
|
636
|
+
define_method(tag) do |props = {}, &block|
|
|
637
|
+
# @type self: Component
|
|
638
|
+
children = [] #: Array[Funicular::VDOM::child_t]
|
|
639
|
+
|
|
640
|
+
if block
|
|
641
|
+
prev_children = @current_children
|
|
642
|
+
@current_children = children
|
|
643
|
+
# @type var block: Proc
|
|
644
|
+
result = block.call
|
|
645
|
+
@current_children = prev_children
|
|
646
|
+
|
|
647
|
+
# Add block result to children if not already added via add_child
|
|
648
|
+
if result && !result.equal?(children) && children.empty?
|
|
649
|
+
normalized = normalize_vnode(result)
|
|
650
|
+
children << normalized if normalized
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Normalize props (convert StyleValue to String for :class)
|
|
655
|
+
normalized_props = {}
|
|
656
|
+
# @type var props: Hash[Symbol, String]
|
|
657
|
+
props.each do |key, value|
|
|
658
|
+
if key == :class && value.is_a?(StyleValue)
|
|
659
|
+
normalized_props[key] = value.to_s
|
|
660
|
+
else
|
|
661
|
+
normalized_props[key] = value
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# @type var normalized_props: Hash[Symbol, String]
|
|
666
|
+
element = VDOM::Element.new(tag, normalized_props, children)
|
|
667
|
+
|
|
668
|
+
# If we're inside another element's block, add this element to parent's children
|
|
669
|
+
if @rendering && @current_children
|
|
670
|
+
@current_children << element
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
element
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Helper to add children in DSL blocks
|
|
678
|
+
def add_child(child)
|
|
679
|
+
return unless @rendering && @current_children
|
|
680
|
+
|
|
681
|
+
normalized = normalize_vnode(child)
|
|
682
|
+
@current_children << normalized if normalized
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Helper method to render child components in DSL
|
|
686
|
+
# Accepts optional block for passing children to the component
|
|
687
|
+
def component(component_class, props = {}, &block)
|
|
688
|
+
unless component_class.is_a?(Class) && component_class.ancestors.include?(Funicular::Component)
|
|
689
|
+
raise "component() expects a Funicular::Component class"
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# If a block is provided, store the children builder in props
|
|
693
|
+
# This allows components like ErrorBoundary to control child rendering
|
|
694
|
+
if block
|
|
695
|
+
props = props.merge(children_block: block)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
vnode = VDOM::Component.new(component_class, props)
|
|
699
|
+
|
|
700
|
+
# If we're inside another element's block, add this component to parent's children
|
|
701
|
+
if @rendering && @current_children
|
|
702
|
+
@current_children << vnode
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
vnode
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Rails-style form_for helper
|
|
709
|
+
def form_for(model_key, options = {}, &block)
|
|
710
|
+
on_submit = options.delete(:on_submit)
|
|
711
|
+
form_class = options.delete(:class)
|
|
712
|
+
|
|
713
|
+
# Build submit handler
|
|
714
|
+
submit_handler = if on_submit
|
|
715
|
+
->(event) do
|
|
716
|
+
event.preventDefault
|
|
717
|
+
|
|
718
|
+
# Collect form data from state
|
|
719
|
+
model_data = state.send(model_key)
|
|
720
|
+
form_data = if model_data.is_a?(Hash)
|
|
721
|
+
model_data
|
|
722
|
+
elsif model_data.respond_to?(:instance_variables)
|
|
723
|
+
data = {} #: Hash[Symbol, untyped]
|
|
724
|
+
model_data.instance_variables.each do |var|
|
|
725
|
+
key = var.to_s.sub('@', '').to_sym
|
|
726
|
+
data[key] = model_data.instance_variable_get(var)
|
|
727
|
+
end
|
|
728
|
+
data
|
|
729
|
+
else
|
|
730
|
+
{} #: Hash[Symbol, untyped]
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Call the submit handler (Symbol, Method, or Proc)
|
|
734
|
+
case on_submit
|
|
735
|
+
when Symbol
|
|
736
|
+
send(on_submit, form_data)
|
|
737
|
+
when Method
|
|
738
|
+
on_submit.call(form_data)
|
|
739
|
+
when Proc
|
|
740
|
+
on_submit.call(form_data)
|
|
741
|
+
else
|
|
742
|
+
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
else
|
|
746
|
+
->(event) { event.preventDefault }
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Render form
|
|
750
|
+
form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
|
|
751
|
+
builder = Funicular::FormBuilder.new(self, model_key, options)
|
|
752
|
+
block.call(builder)
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Rails-style link_to helper
|
|
757
|
+
# Default behavior: Fetch API for same-page actions (SPA-friendly)
|
|
758
|
+
# Use navigate: true for router navigation (different component tree)
|
|
759
|
+
def link_to(path, method: :get, navigate: false, **options, &block)
|
|
760
|
+
if navigate
|
|
761
|
+
# Navigation: Use <a> tag with real href for browser features
|
|
762
|
+
# (right-click -> open in new tab, link preview, etc.)
|
|
763
|
+
# Note: preventDefault is automatically called by js_add_event_listener
|
|
764
|
+
# for <a> tags to enable SPA navigation
|
|
765
|
+
merged_options = options.merge(href: path)
|
|
766
|
+
merged_options[:onclick] = ->(event) {
|
|
767
|
+
event.preventDefault # Called for clarity, but already handled by JS layer
|
|
768
|
+
handle_link_click(path)
|
|
769
|
+
}
|
|
770
|
+
a(merged_options, &block)
|
|
771
|
+
else
|
|
772
|
+
# Action: Use <div> tag (semantically more appropriate for actions)
|
|
773
|
+
# No href needed, purely onclick-driven
|
|
774
|
+
merged_options = options.merge(
|
|
775
|
+
onclick: -> { handle_link_with_method(path, method) }
|
|
776
|
+
)
|
|
777
|
+
div(merged_options, &block)
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Handle router navigation (navigate using History API)
|
|
782
|
+
def handle_link_click(path)
|
|
783
|
+
Funicular.router&.navigate(path)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Handle link action via Fetch API
|
|
787
|
+
def handle_link_with_method(path, method)
|
|
788
|
+
# Call appropriate HTTP method
|
|
789
|
+
case method.to_s.downcase.to_sym
|
|
790
|
+
when :get
|
|
791
|
+
HTTP.get(path) { |response| handle_link_response(response, path, method) }
|
|
792
|
+
when :post
|
|
793
|
+
HTTP.post(path) { |response| handle_link_response(response, path, method) }
|
|
794
|
+
when :put
|
|
795
|
+
HTTP.put(path) { |response| handle_link_response(response, path, method) }
|
|
796
|
+
when :patch
|
|
797
|
+
HTTP.patch(path) { |response| handle_link_response(response, path, method) }
|
|
798
|
+
when :delete
|
|
799
|
+
HTTP.delete(path) { |response| handle_link_response(response, path, method) }
|
|
800
|
+
else
|
|
801
|
+
raise "Unsupported HTTP method: #{method}"
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Handle response from link action (can be overridden by subclasses)
|
|
806
|
+
def handle_link_response(response, path, method)
|
|
807
|
+
if response.error?
|
|
808
|
+
puts "Link action failed (#{method.to_s.upcase} #{path}): #{response.error_message}"
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Enable URL helpers from RouteHelpers module and suspense data accessors
|
|
813
|
+
def method_missing(method, *args)
|
|
814
|
+
# Check for suspense data accessor
|
|
815
|
+
if self.class.suspense_definitions.key?(method)
|
|
816
|
+
return @suspense_data[method]
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
if Funicular.const_defined?(:RouteHelpers)
|
|
820
|
+
helpers = Funicular::RouteHelpers
|
|
821
|
+
if helpers.instance_methods.include?(method)
|
|
822
|
+
# Include helpers module and retry
|
|
823
|
+
self.class.include(helpers) unless self.class.include?(helpers)
|
|
824
|
+
return send(method, *args)
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
super
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def respond_to_missing?(method, include_private = false)
|
|
831
|
+
# Check for suspense data accessor
|
|
832
|
+
return true if self.class.suspense_definitions.key?(method)
|
|
833
|
+
|
|
834
|
+
if Funicular.const_defined?(:RouteHelpers)
|
|
835
|
+
Funicular::RouteHelpers.instance_methods.include?(method) || super
|
|
836
|
+
else
|
|
837
|
+
super
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
# Transition Helpers
|
|
842
|
+
# These methods provide a declarative way to animate element removal/addition
|
|
843
|
+
# using CSS transitions, inspired by Vue.js and Alpine.js transition systems
|
|
844
|
+
|
|
845
|
+
# Remove an element via CSS transition animation
|
|
846
|
+
#
|
|
847
|
+
# @param element_id [String] DOM element ID (without '#' prefix)
|
|
848
|
+
# @param from [String] CSS classes to remove before animation
|
|
849
|
+
# @param to [String] CSS classes to add for leave animation
|
|
850
|
+
# @param duration [Integer] Animation duration in milliseconds (default: 300)
|
|
851
|
+
# @param callback [Proc] Block called after animation completes
|
|
852
|
+
#
|
|
853
|
+
# @example Remove message with fade out
|
|
854
|
+
# remove_via("message-123",
|
|
855
|
+
# "opacity-100 max-h-screen"
|
|
856
|
+
# "opacity-0 max-h-0",
|
|
857
|
+
# duration: 500,
|
|
858
|
+
# ) do
|
|
859
|
+
# patch(messages: updated_messages)
|
|
860
|
+
# end
|
|
861
|
+
def remove_via(element_id, from, to, duration: 300, &callback)
|
|
862
|
+
element = JS.document.getElementById(element_id)
|
|
863
|
+
|
|
864
|
+
unless element
|
|
865
|
+
callback.call if callback
|
|
866
|
+
return
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
element.classList.remove(*from.split(" ")) unless from.empty?
|
|
870
|
+
element.classList.add(*to.split(" ")) unless to.empty?
|
|
871
|
+
|
|
872
|
+
JS.global.setTimeout(duration) do
|
|
873
|
+
callback&.call
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Add an element via CSS transition animation
|
|
878
|
+
#
|
|
879
|
+
# @param element_id [String] DOM element ID (without '#' prefix)
|
|
880
|
+
# @param from [String] CSS classes to remove before animation
|
|
881
|
+
# @param to [String] CSS classes to add for leave animation
|
|
882
|
+
# @param duration [Integer] Animation duration in milliseconds (default: 300)
|
|
883
|
+
# @param callback [Proc] Block called after animation completes
|
|
884
|
+
#
|
|
885
|
+
# @example Add message with fade in
|
|
886
|
+
# add_via("message-456",
|
|
887
|
+
# "opacity-0 scale-95",
|
|
888
|
+
# "opacity-100 scale-100",
|
|
889
|
+
# duration: 300
|
|
890
|
+
# )
|
|
891
|
+
def add_via(element_id, from, to, duration: 300, &callback)
|
|
892
|
+
element = JS.document.getElementById(element_id)
|
|
893
|
+
|
|
894
|
+
unless element
|
|
895
|
+
callback.call if callback
|
|
896
|
+
return
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
element.classList.add(*from.split(" ")) unless from.empty?
|
|
900
|
+
|
|
901
|
+
sleep_ms 10
|
|
902
|
+
|
|
903
|
+
element.classList.remove(*from.split(" ")) unless from.empty?
|
|
904
|
+
element.classList.add(*to.split(" ")) unless to.empty?
|
|
905
|
+
|
|
906
|
+
JS.global.setTimeout(duration) do
|
|
907
|
+
callback&.call
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
end
|
|
911
|
+
end
|