funicular 0.2.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +5 -5
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +17 -17
- data/demo/test_component.html +15 -15
- data/demo/test_error_boundary.html +41 -41
- data/demo/test_router.html +57 -57
- data/demo/tic-tac-toe.html +29 -29
- data/docs/architecture.md +43 -30
- data/lib/funicular/compiler.rb +15 -16
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +2 -0
- data/lib/funicular/ssr.rb +2 -1
- data/lib/funicular/testing/node_runner.rb +1 -1
- data/lib/funicular/vendor/mrbc/VERSION +1 -0
- data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +14 -1
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +38 -23
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +47 -23
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
- data/lib/funicular/version.rb +1 -1
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +47 -46
- data/lib/tasks/funicular.rake +1 -1
- data/minitest/commands_routes_test.rb +97 -0
- data/minitest/compiler_test.rb +195 -0
- data/minitest/configuration_test.rb +64 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +6 -6
- data/minitest/form_for_test.rb +106 -0
- data/minitest/funicular_test.rb +28 -2
- data/minitest/hydration_test.rb +2 -2
- data/minitest/middleware_test.rb +154 -0
- data/minitest/picoruby_helper_test.rb +139 -0
- data/minitest/route_parser_test.rb +139 -0
- data/minitest/schema_test.rb +23 -0
- data/minitest/ssr_test.rb +57 -0
- data/minitest/support/rails_stub.rb +59 -0
- data/minitest/test_helper.rb +20 -0
- data/minitest/testing_test.rb +267 -0
- data/minitest/view_context_test.rb +101 -0
- data/mrblib/component.rb +210 -229
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +19 -27
- data/mrblib/form_builder.rb +34 -24
- data/mrblib/funicular.rb +2 -1
- data/mrblib/html_serializer.rb +14 -13
- data/mrblib/http.rb +12 -1
- data/mrblib/patcher.rb +16 -9
- data/mrblib/router.rb +9 -5
- data/mrblib/runtime.rb +28 -0
- data/mrblib/styles.rb +13 -17
- data/mrblib/vdom.rb +92 -9
- data/mrblib/view_context.rb +135 -0
- data/sig/component.rbs +34 -85
- data/sig/error_boundary.rbs +4 -4
- data/sig/form_builder.rbs +2 -1
- data/sig/html_serializer.rbs +2 -1
- data/sig/http.rbs +1 -0
- data/sig/patcher.rbs +1 -1
- data/sig/router.rbs +2 -13
- data/sig/runtime.rbs +13 -0
- data/sig/styles.rbs +3 -4
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +47 -0
- metadata +19 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
data/mrblib/component.rb
CHANGED
|
@@ -5,46 +5,77 @@ module Funicular
|
|
|
5
5
|
@state = state_hash
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
# Support dynamic key access: state[:key] or state[variable_key]
|
|
9
8
|
def [](key)
|
|
10
9
|
@state[key]
|
|
11
10
|
end
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
|
|
12
|
+
def fetch(key, default = nil)
|
|
13
|
+
return @state.fetch(key) if default.nil? && @state.key?(key)
|
|
14
|
+
@state.fetch(key, default)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def key?(key)
|
|
18
|
+
@state.key?(key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
@state
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ResourceAccessor
|
|
27
|
+
def initialize(data, states, errors)
|
|
28
|
+
@data = data
|
|
29
|
+
@states = states
|
|
30
|
+
@errors = errors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def [](key)
|
|
34
|
+
@data[key]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch(key, default = nil)
|
|
38
|
+
return @data.fetch(key) if default.nil? && @data.key?(key)
|
|
39
|
+
@data.fetch(key, default)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def loading?(key)
|
|
43
|
+
@states[key] == :pending || @states[key] == :loading
|
|
19
44
|
end
|
|
20
45
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
def error?(key)
|
|
47
|
+
@states[key] == :rejected
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def error(key)
|
|
51
|
+
@errors[key]
|
|
24
52
|
end
|
|
25
53
|
end
|
|
26
54
|
|
|
27
|
-
attr_accessor :props, :vdom, :dom_element, :mounted
|
|
55
|
+
attr_accessor :props, :vdom, :dom_element, :mounted, :runtime, :children, :current_children
|
|
28
56
|
attr_reader :refs
|
|
29
57
|
|
|
30
58
|
def initialize(props = {})
|
|
31
59
|
@props = props
|
|
32
60
|
@state = initialize_state || {}
|
|
33
61
|
@state_accessor = nil
|
|
62
|
+
@resource_accessor = nil
|
|
34
63
|
@style_accessor = nil
|
|
64
|
+
@runtime = Funicular::Runtime.new
|
|
65
|
+
@children = [] #: Array[Funicular::VDOM::child_t]
|
|
35
66
|
@vdom = nil
|
|
36
67
|
@dom_element = nil
|
|
37
|
-
@refs = {}
|
|
38
|
-
@event_listeners = []
|
|
68
|
+
@refs = {} #: Hash[Symbol, JS::Element]
|
|
69
|
+
@event_listeners = [] #: Array[untyped]
|
|
39
70
|
@mounted = false
|
|
40
71
|
@updating = false
|
|
41
|
-
@child_components = []
|
|
72
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
42
73
|
|
|
43
74
|
# Initialize suspense state
|
|
44
|
-
@suspense_data = {}
|
|
45
|
-
@suspense_states = {}
|
|
46
|
-
@suspense_errors = {}
|
|
47
|
-
@suspense_pending_timers = []
|
|
75
|
+
@suspense_data = {} #: Hash[Symbol, untyped]
|
|
76
|
+
@suspense_states = {} #: Hash[Symbol, Symbol]
|
|
77
|
+
@suspense_errors = {} #: Hash[Symbol, untyped]
|
|
78
|
+
@suspense_pending_timers = [] #: Array[untyped]
|
|
48
79
|
self.class.suspense_definitions.each_key do |name|
|
|
49
80
|
@suspense_states[name] = :pending
|
|
50
81
|
end
|
|
@@ -57,6 +88,14 @@ module Funicular
|
|
|
57
88
|
@state_accessor ||= StateAccessor.new(@state)
|
|
58
89
|
end
|
|
59
90
|
|
|
91
|
+
def resources
|
|
92
|
+
@resource_accessor ||= ResourceAccessor.new(@suspense_data, @suspense_states, @suspense_errors)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def styles
|
|
96
|
+
@style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
|
|
97
|
+
end
|
|
98
|
+
|
|
60
99
|
# Override this method in subclasses to define initial state
|
|
61
100
|
def initialize_state
|
|
62
101
|
{}
|
|
@@ -66,7 +105,7 @@ module Funicular
|
|
|
66
105
|
# Used by SSR to inject server data, and by client hydration to restore
|
|
67
106
|
# the same state from window.__FUNICULAR_STATE__ so the first client
|
|
68
107
|
# render matches the server HTML. Top-level keys are symbolized so they
|
|
69
|
-
# are reachable via state
|
|
108
|
+
# are reachable via state[:foo] (StateAccessor uses symbol keys); nested
|
|
70
109
|
# values are left untouched (components read them with string keys).
|
|
71
110
|
def seed_state(state_hash)
|
|
72
111
|
return self if state_hash.nil?
|
|
@@ -191,30 +230,25 @@ module Funicular
|
|
|
191
230
|
# ) do
|
|
192
231
|
# div { user.name }
|
|
193
232
|
# end
|
|
194
|
-
def
|
|
233
|
+
def render_suspense(h, name, fallback:, error: nil, &block)
|
|
195
234
|
current_children = @current_children
|
|
196
235
|
child_count_before = current_children&.size
|
|
197
236
|
result = nil
|
|
198
237
|
|
|
199
|
-
|
|
200
|
-
rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
|
|
201
|
-
if rejected_name
|
|
238
|
+
if @suspense_states[name] == :rejected
|
|
202
239
|
result = if error
|
|
203
|
-
error.call(@suspense_errors[
|
|
240
|
+
error.call(h, @suspense_errors[name])
|
|
204
241
|
else
|
|
205
|
-
fallback.call
|
|
242
|
+
fallback.call(h)
|
|
206
243
|
end
|
|
207
|
-
elsif suspense_loading?
|
|
208
|
-
|
|
209
|
-
# Note: Loading is started in mount(), not here
|
|
210
|
-
result = fallback.call
|
|
244
|
+
elsif suspense_loading?(name)
|
|
245
|
+
result = fallback.call(h)
|
|
211
246
|
else
|
|
212
|
-
|
|
213
|
-
result = block.call
|
|
247
|
+
result = block.call(h, resources)
|
|
214
248
|
end
|
|
215
249
|
|
|
216
250
|
if current_children && current_children.size == child_count_before
|
|
217
|
-
|
|
251
|
+
add_child_from_view(result)
|
|
218
252
|
end
|
|
219
253
|
|
|
220
254
|
result
|
|
@@ -223,7 +257,7 @@ module Funicular
|
|
|
223
257
|
# Class methods for styles DSL
|
|
224
258
|
def self.styles(&block)
|
|
225
259
|
builder = StyleBuilder.new
|
|
226
|
-
|
|
260
|
+
block.call(builder)
|
|
227
261
|
@styles_definitions = builder.to_definitions
|
|
228
262
|
end
|
|
229
263
|
|
|
@@ -256,12 +290,7 @@ module Funicular
|
|
|
256
290
|
end
|
|
257
291
|
|
|
258
292
|
def self.suspense_definitions
|
|
259
|
-
@suspense_definitions ||= {}
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# Instance method to access styles
|
|
263
|
-
def s
|
|
264
|
-
@style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
|
|
293
|
+
@suspense_definitions ||= {}
|
|
265
294
|
end
|
|
266
295
|
|
|
267
296
|
# Update state and trigger re-render
|
|
@@ -301,7 +330,7 @@ module Funicular
|
|
|
301
330
|
|
|
302
331
|
@container = container
|
|
303
332
|
new_vdom = build_vdom
|
|
304
|
-
@dom_element = VDOM::Renderer.new.render(new_vdom)
|
|
333
|
+
@dom_element = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
|
|
305
334
|
bind_events(@dom_element, new_vdom)
|
|
306
335
|
collect_refs(@dom_element, new_vdom)
|
|
307
336
|
collect_child_components(new_vdom)
|
|
@@ -397,7 +426,7 @@ module Funicular
|
|
|
397
426
|
@child_components.each do |child|
|
|
398
427
|
child.unmount if child.respond_to?(:unmount)
|
|
399
428
|
end
|
|
400
|
-
@child_components = []
|
|
429
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
401
430
|
|
|
402
431
|
cleanup_events
|
|
403
432
|
cleanup_suspense_timers
|
|
@@ -418,17 +447,23 @@ module Funicular
|
|
|
418
447
|
end
|
|
419
448
|
|
|
420
449
|
# Override this method in subclasses to define render logic
|
|
421
|
-
def render
|
|
422
|
-
raise "Subclasses must implement
|
|
450
|
+
def render(h)
|
|
451
|
+
raise "Subclasses must implement render(h)"
|
|
423
452
|
end
|
|
424
453
|
|
|
425
454
|
# Build VDOM tree from render method
|
|
426
455
|
# Called by VDOM::Renderer, Differ, and Patcher
|
|
427
456
|
def build_vdom
|
|
457
|
+
previous_rendering = @rendering
|
|
458
|
+
previous_children = @current_children
|
|
428
459
|
@rendering = true
|
|
429
460
|
@current_children = nil
|
|
430
|
-
|
|
431
|
-
|
|
461
|
+
begin
|
|
462
|
+
result = render(ViewContext.new(self))
|
|
463
|
+
ensure
|
|
464
|
+
@rendering = previous_rendering
|
|
465
|
+
@current_children = previous_children
|
|
466
|
+
end
|
|
432
467
|
|
|
433
468
|
# Convert render result to VNode
|
|
434
469
|
vnode = normalize_vnode(result)
|
|
@@ -452,7 +487,7 @@ module Funicular
|
|
|
452
487
|
|
|
453
488
|
vnode.props.each do |key, value|
|
|
454
489
|
key_str = key.to_s
|
|
455
|
-
next unless
|
|
490
|
+
next unless VDOM.event_attribute?(key_str)
|
|
456
491
|
|
|
457
492
|
event_name = key_str[2..-1]&.downcase || ""
|
|
458
493
|
event_types << event_name
|
|
@@ -563,7 +598,7 @@ module Funicular
|
|
|
563
598
|
@event_listeners.each do |callback_id|
|
|
564
599
|
JS::Object.removeEventListener(callback_id)
|
|
565
600
|
end
|
|
566
|
-
@event_listeners = []
|
|
601
|
+
@event_listeners = [] #: Array[untyped]
|
|
567
602
|
|
|
568
603
|
# NOTE: Do NOT cleanup child component events here!
|
|
569
604
|
# Child components manage their own events and will cleanup
|
|
@@ -576,7 +611,74 @@ module Funicular
|
|
|
576
611
|
@suspense_pending_timers.each do |timer_id|
|
|
577
612
|
JS.global.clearTimeout(timer_id)
|
|
578
613
|
end
|
|
579
|
-
@suspense_pending_timers = []
|
|
614
|
+
@suspense_pending_timers = [] #: Array[untyped]
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
private
|
|
618
|
+
|
|
619
|
+
public
|
|
620
|
+
|
|
621
|
+
def normalize_vnode_for_view(value)
|
|
622
|
+
normalize_vnode(value)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def add_child_from_view(child)
|
|
626
|
+
current_children = @current_children
|
|
627
|
+
return unless @rendering && current_children
|
|
628
|
+
|
|
629
|
+
normalized = normalize_vnode(child)
|
|
630
|
+
current_children << normalized if normalized
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def build_form_for(h, model_key, options = {}, &block)
|
|
634
|
+
on_submit = options.delete(:on_submit)
|
|
635
|
+
form_class = options.delete(:class)
|
|
636
|
+
|
|
637
|
+
submit_handler = if on_submit
|
|
638
|
+
->(event) do
|
|
639
|
+
event.preventDefault
|
|
640
|
+
form_data = collect_form_data(event, model_key)
|
|
641
|
+
|
|
642
|
+
case on_submit
|
|
643
|
+
when Symbol
|
|
644
|
+
send(on_submit, form_data)
|
|
645
|
+
when Method
|
|
646
|
+
on_submit.call(form_data)
|
|
647
|
+
when Proc
|
|
648
|
+
on_submit.call(form_data)
|
|
649
|
+
else
|
|
650
|
+
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
else
|
|
654
|
+
->(event) { event.preventDefault }
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
h.form({ onsubmit: submit_handler, class: form_class }.merge(options)) do |hh|
|
|
658
|
+
builder = Funicular::FormBuilder.new(self, hh, model_key, options)
|
|
659
|
+
block.call(builder)
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def build_link_to(h, path, method: :get, **options, &block)
|
|
664
|
+
merged_options = options.merge(href: path)
|
|
665
|
+
merged_options[:onclick] = ->(event) {
|
|
666
|
+
event.preventDefault
|
|
667
|
+
if method.to_s.downcase.to_sym == :get
|
|
668
|
+
handle_link_click(path)
|
|
669
|
+
else
|
|
670
|
+
handle_link_with_method(path, method)
|
|
671
|
+
end
|
|
672
|
+
}
|
|
673
|
+
h.a(merged_options, &block)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def build_button_to(h, path, method: :post, **options, &block)
|
|
677
|
+
merged_options = options.merge(
|
|
678
|
+
type: options[:type] || "button",
|
|
679
|
+
onclick: -> { handle_link_with_method(path, method) }
|
|
680
|
+
)
|
|
681
|
+
h.button(merged_options, &block)
|
|
580
682
|
end
|
|
581
683
|
|
|
582
684
|
private
|
|
@@ -615,7 +717,7 @@ module Funicular
|
|
|
615
717
|
cleanup_events
|
|
616
718
|
|
|
617
719
|
unless patches.empty?
|
|
618
|
-
new_dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
|
|
720
|
+
new_dom_element = VDOM::Patcher.new(nil, @runtime).apply(@dom_element, patches)
|
|
619
721
|
# apply returns JS::Object (it must accept text-node patches), but
|
|
620
722
|
# the component's root is always an Element. Narrow to JS::Element.
|
|
621
723
|
@dom_element = new_dom_element if new_dom_element.is_a?(JS::Element)
|
|
@@ -698,7 +800,7 @@ module Funicular
|
|
|
698
800
|
# appendChild: the stale node already has a place in the document, so we
|
|
699
801
|
# replaceChild instead.
|
|
700
802
|
def full_render_fallback(new_vdom, server_dom)
|
|
701
|
-
fresh = VDOM::Renderer.new.render(new_vdom)
|
|
803
|
+
fresh = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
|
|
702
804
|
parent = server_dom.parentNode
|
|
703
805
|
parent.replaceChild(fresh, server_dom) if parent
|
|
704
806
|
bind_events(fresh, new_vdom)
|
|
@@ -721,6 +823,8 @@ module Funicular
|
|
|
721
823
|
|
|
722
824
|
if child.is_a?(VDOM::Component)
|
|
723
825
|
instance = child.component_class.new(child.props)
|
|
826
|
+
instance.runtime = child.runtime || @runtime
|
|
827
|
+
instance.children = child.children
|
|
724
828
|
child.instance = instance
|
|
725
829
|
instance.hydrate(child_dom)
|
|
726
830
|
elsif child.is_a?(VDOM::Element)
|
|
@@ -731,7 +835,7 @@ module Funicular
|
|
|
731
835
|
|
|
732
836
|
# Collect child component instances from VDOM tree
|
|
733
837
|
def collect_child_components(vnode)
|
|
734
|
-
@child_components = []
|
|
838
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
735
839
|
collect_child_components_recursive(vnode, @child_components)
|
|
736
840
|
end
|
|
737
841
|
|
|
@@ -750,176 +854,82 @@ module Funicular
|
|
|
750
854
|
end
|
|
751
855
|
end
|
|
752
856
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
h1 h2 h3 h4 h5 h6
|
|
757
|
-
ul ol li
|
|
758
|
-
table thead tbody tr th td
|
|
759
|
-
form input textarea button select option label
|
|
760
|
-
header footer nav section article aside
|
|
761
|
-
img video audio canvas
|
|
762
|
-
br hr
|
|
763
|
-
]
|
|
764
|
-
|
|
765
|
-
# The HTML tag DSL methods are public: besides being called inside render
|
|
766
|
-
# (implicit self), they are invoked by collaborators such as FormBuilder
|
|
767
|
-
# with an explicit receiver (@component.div). A private method would forbid
|
|
768
|
-
# that under CRuby (it is tolerated under mruby), which would break SSR of
|
|
769
|
-
# any component using form_for. Keep them public on both VMs.
|
|
770
|
-
public
|
|
771
|
-
|
|
772
|
-
HTML_TAGS.each do |tag|
|
|
773
|
-
define_method(tag) do |props = {}, &block|
|
|
774
|
-
# @type self: Component
|
|
775
|
-
children = [] #: Array[Funicular::VDOM::child_t]
|
|
776
|
-
|
|
777
|
-
if block
|
|
778
|
-
prev_children = @current_children
|
|
779
|
-
@current_children = children
|
|
780
|
-
# @type var block: Proc
|
|
781
|
-
result = block.call
|
|
782
|
-
@current_children = prev_children
|
|
783
|
-
|
|
784
|
-
# Add block result to children if not already added via add_child
|
|
785
|
-
if result && !result.equal?(children) && children.empty?
|
|
786
|
-
normalized = normalize_vnode(result)
|
|
787
|
-
children << normalized if normalized
|
|
788
|
-
end
|
|
789
|
-
end
|
|
790
|
-
|
|
791
|
-
# Normalize props (convert StyleValue to String for :class)
|
|
792
|
-
normalized_props = {}
|
|
793
|
-
# @type var props: Hash[Symbol, String]
|
|
794
|
-
props.each do |key, value|
|
|
795
|
-
if key == :class && value.is_a?(StyleValue)
|
|
796
|
-
normalized_props[key] = value.to_s
|
|
797
|
-
else
|
|
798
|
-
normalized_props[key] = value
|
|
799
|
-
end
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
# @type var normalized_props: Hash[Symbol, String]
|
|
803
|
-
element = VDOM::Element.new(tag, normalized_props, children)
|
|
804
|
-
|
|
805
|
-
# If we're inside another element's block, add this element to parent's children
|
|
806
|
-
if @rendering && @current_children
|
|
807
|
-
@current_children << element
|
|
808
|
-
end
|
|
809
|
-
|
|
810
|
-
element
|
|
811
|
-
end
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
private
|
|
815
|
-
|
|
816
|
-
# Helper to add children in DSL blocks
|
|
817
|
-
def add_child(child)
|
|
818
|
-
return unless @rendering && @current_children
|
|
857
|
+
def collect_form_data(event, model_key)
|
|
858
|
+
form_data = collect_dom_form_data(event)
|
|
859
|
+
return form_data unless form_data.empty?
|
|
819
860
|
|
|
820
|
-
|
|
821
|
-
@current_children << normalized if normalized
|
|
861
|
+
collect_state_form_data(model_key)
|
|
822
862
|
end
|
|
823
863
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
864
|
+
def collect_dom_form_data(event)
|
|
865
|
+
form = event_target(event)
|
|
866
|
+
return {} unless form
|
|
867
|
+
|
|
868
|
+
elements = form[:elements]
|
|
869
|
+
return {} unless elements
|
|
870
|
+
|
|
871
|
+
data = {} #: Hash[Symbol, untyped]
|
|
872
|
+
length_value = elements[:length]
|
|
873
|
+
length = length_value ? length_value.to_i : 0
|
|
874
|
+
i = 0
|
|
875
|
+
while i < length
|
|
876
|
+
field = elements[i]
|
|
877
|
+
add_form_field_value(data, field) if field
|
|
878
|
+
i += 1
|
|
829
879
|
end
|
|
880
|
+
data
|
|
881
|
+
end
|
|
830
882
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
vnode = VDOM::Component.new(component_class, props)
|
|
838
|
-
|
|
839
|
-
# If we're inside another element's block, add this component to parent's children
|
|
840
|
-
if @rendering && @current_children
|
|
841
|
-
@current_children << vnode
|
|
883
|
+
def event_target(event)
|
|
884
|
+
target = nil
|
|
885
|
+
begin
|
|
886
|
+
target = event[:target]
|
|
887
|
+
rescue
|
|
888
|
+
target = nil
|
|
842
889
|
end
|
|
890
|
+
return target if target
|
|
843
891
|
|
|
844
|
-
|
|
892
|
+
event.target if event.respond_to?(:target)
|
|
845
893
|
end
|
|
846
894
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
form_class = options.delete(:class)
|
|
851
|
-
|
|
852
|
-
# Build submit handler
|
|
853
|
-
submit_handler = if on_submit
|
|
854
|
-
->(event) do
|
|
855
|
-
event.preventDefault
|
|
895
|
+
def add_form_field_value(data, field)
|
|
896
|
+
name = field[:name].to_s
|
|
897
|
+
return if name.empty?
|
|
856
898
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
model_data
|
|
861
|
-
elsif model_data.respond_to?(:instance_variables)
|
|
862
|
-
data = {} #: Hash[Symbol, untyped]
|
|
863
|
-
model_data.instance_variables.each do |var|
|
|
864
|
-
key = var.to_s.sub('@', '').to_sym
|
|
865
|
-
data[key] = model_data.instance_variable_get(var)
|
|
866
|
-
end
|
|
867
|
-
data
|
|
868
|
-
else
|
|
869
|
-
{} #: Hash[Symbol, untyped]
|
|
870
|
-
end
|
|
899
|
+
type = field[:type].to_s.downcase
|
|
900
|
+
return if %w[submit button reset].include?(type)
|
|
901
|
+
return if type == "radio" && !field[:checked]
|
|
871
902
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
when Method
|
|
877
|
-
on_submit.call(form_data)
|
|
878
|
-
when Proc
|
|
879
|
-
on_submit.call(form_data)
|
|
880
|
-
else
|
|
881
|
-
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
882
|
-
end
|
|
883
|
-
end
|
|
903
|
+
value = if type == "checkbox"
|
|
904
|
+
field[:checked]
|
|
905
|
+
elsif type == "file"
|
|
906
|
+
field[:files]
|
|
884
907
|
else
|
|
885
|
-
|
|
908
|
+
field[:value]
|
|
886
909
|
end
|
|
887
910
|
|
|
888
|
-
|
|
889
|
-
form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
|
|
890
|
-
builder = Funicular::FormBuilder.new(self, model_key, options)
|
|
891
|
-
block.call(builder)
|
|
892
|
-
end
|
|
911
|
+
data[name.to_sym] = value
|
|
893
912
|
end
|
|
894
913
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
event.preventDefault # Called for clarity, but already handled by JS layer
|
|
907
|
-
handle_link_click(path)
|
|
908
|
-
}
|
|
909
|
-
a(merged_options, &block)
|
|
914
|
+
def collect_state_form_data(model_key)
|
|
915
|
+
model_data = state[model_key]
|
|
916
|
+
if model_data.is_a?(Hash)
|
|
917
|
+
model_data
|
|
918
|
+
elsif model_data.respond_to?(:instance_variables)
|
|
919
|
+
data = {} #: Hash[Symbol, untyped]
|
|
920
|
+
model_data.instance_variables.each do |var|
|
|
921
|
+
key = var.to_s.sub('@', '').to_sym
|
|
922
|
+
data[key] = model_data.instance_variable_get(var)
|
|
923
|
+
end
|
|
924
|
+
data
|
|
910
925
|
else
|
|
911
|
-
|
|
912
|
-
# No href needed, purely onclick-driven
|
|
913
|
-
merged_options = options.merge(
|
|
914
|
-
onclick: -> { handle_link_with_method(path, method) }
|
|
915
|
-
)
|
|
916
|
-
div(merged_options, &block)
|
|
926
|
+
{} #: Hash[Symbol, untyped]
|
|
917
927
|
end
|
|
918
928
|
end
|
|
919
929
|
|
|
920
930
|
# Handle router navigation (navigate using History API)
|
|
921
931
|
def handle_link_click(path)
|
|
922
|
-
|
|
932
|
+
@runtime.router&.navigate(path)
|
|
923
933
|
end
|
|
924
934
|
|
|
925
935
|
# Handle link action via Fetch API
|
|
@@ -948,35 +958,6 @@ module Funicular
|
|
|
948
958
|
end
|
|
949
959
|
end
|
|
950
960
|
|
|
951
|
-
# Enable URL helpers from RouteHelpers module and suspense data accessors
|
|
952
|
-
def method_missing(method, *args)
|
|
953
|
-
# Check for suspense data accessor
|
|
954
|
-
if self.class.suspense_definitions.key?(method)
|
|
955
|
-
return @suspense_data[method]
|
|
956
|
-
end
|
|
957
|
-
|
|
958
|
-
if Funicular.const_defined?(:RouteHelpers)
|
|
959
|
-
helpers = Funicular::RouteHelpers
|
|
960
|
-
if helpers.instance_methods.include?(method)
|
|
961
|
-
# Include helpers module and retry
|
|
962
|
-
self.class.include(helpers) unless self.class.include?(helpers)
|
|
963
|
-
return send(method, *args)
|
|
964
|
-
end
|
|
965
|
-
end
|
|
966
|
-
super
|
|
967
|
-
end
|
|
968
|
-
|
|
969
|
-
def respond_to_missing?(method, include_private = false)
|
|
970
|
-
# Check for suspense data accessor
|
|
971
|
-
return true if self.class.suspense_definitions.key?(method)
|
|
972
|
-
|
|
973
|
-
if Funicular.const_defined?(:RouteHelpers)
|
|
974
|
-
Funicular::RouteHelpers.instance_methods.include?(method) || super
|
|
975
|
-
else
|
|
976
|
-
super
|
|
977
|
-
end
|
|
978
|
-
end
|
|
979
|
-
|
|
980
961
|
# Transition Helpers
|
|
981
962
|
# These methods provide a declarative way to animate element removal/addition
|
|
982
963
|
# using CSS transitions, inspired by Vue.js and Alpine.js transition systems
|
data/mrblib/debug.rb
CHANGED
|
@@ -152,16 +152,17 @@ module Funicular
|
|
|
152
152
|
|
|
153
153
|
result = {} #: Hash[String, String]
|
|
154
154
|
component.instance_variables.each do |var|
|
|
155
|
-
|
|
156
|
-
next if
|
|
157
|
-
if
|
|
158
|
-
|
|
155
|
+
name = var.to_s
|
|
156
|
+
next if name == '@state'
|
|
157
|
+
next if name.start_with?('@__debug')
|
|
158
|
+
if name == '@vdom' || name == '@child_components'
|
|
159
|
+
result[name] = "<omitted>"
|
|
159
160
|
next
|
|
160
161
|
end
|
|
161
162
|
begin
|
|
162
|
-
result[
|
|
163
|
+
result[name] = component.instance_variable_get(var).inspect
|
|
163
164
|
rescue
|
|
164
|
-
result[
|
|
165
|
+
result[name] = "<error inspecting value>"
|
|
165
166
|
end
|
|
166
167
|
end
|
|
167
168
|
JSON.generate(result)
|
data/mrblib/differ.rb
CHANGED
|
@@ -213,11 +213,13 @@ module Funicular
|
|
|
213
213
|
# If only Proc props changed, update props but skip VDOM rebuild
|
|
214
214
|
unless data_props_changed
|
|
215
215
|
instance.props = new_node.props
|
|
216
|
+
instance.children = new_node.children
|
|
216
217
|
return []
|
|
217
218
|
end
|
|
218
219
|
|
|
219
220
|
old_internal_vdom = instance.vdom
|
|
220
221
|
instance.props = new_node.props
|
|
222
|
+
instance.children = new_node.children
|
|
221
223
|
new_internal_vdom = instance.build_vdom
|
|
222
224
|
internal_patches = diff(old_internal_vdom, new_internal_vdom)
|
|
223
225
|
|
|
@@ -227,7 +229,7 @@ module Funicular
|
|
|
227
229
|
end
|
|
228
230
|
|
|
229
231
|
# Original logic: If props changed, replace the entire component
|
|
230
|
-
if old_node.props != new_node.props
|
|
232
|
+
if old_node.props != new_node.props || old_node.children != new_node.children
|
|
231
233
|
return [[:replace, new_node, old_node]]
|
|
232
234
|
end
|
|
233
235
|
|