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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
@@ -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