ruby_wasm_ui 0.8.1

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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/ruby_comments.mdc +29 -0
  3. data/.github/workflows/playwright.yml +74 -0
  4. data/.github/workflows/rspec.yml +33 -0
  5. data/.node-version +1 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +218 -0
  9. data/Rakefile +4 -0
  10. data/docs/conditional-rendering.md +119 -0
  11. data/docs/lifecycle-hooks.md +75 -0
  12. data/docs/list-rendering.md +51 -0
  13. data/examples/Gemfile +5 -0
  14. data/examples/Gemfile.lock +41 -0
  15. data/examples/Makefile +15 -0
  16. data/examples/npm-packages/runtime/counter/index.html +28 -0
  17. data/examples/npm-packages/runtime/counter/index.rb +62 -0
  18. data/examples/npm-packages/runtime/counter/index.spec.js +42 -0
  19. data/examples/npm-packages/runtime/hello/index.html +28 -0
  20. data/examples/npm-packages/runtime/hello/index.rb +29 -0
  21. data/examples/npm-packages/runtime/hello/index.spec.js +53 -0
  22. data/examples/npm-packages/runtime/input/index.html +28 -0
  23. data/examples/npm-packages/runtime/input/index.rb +46 -0
  24. data/examples/npm-packages/runtime/input/index.spec.js +58 -0
  25. data/examples/npm-packages/runtime/list/index.html +27 -0
  26. data/examples/npm-packages/runtime/list/index.rb +33 -0
  27. data/examples/npm-packages/runtime/list/index.spec.js +46 -0
  28. data/examples/npm-packages/runtime/on_mounted_demo/index.html +40 -0
  29. data/examples/npm-packages/runtime/on_mounted_demo/index.rb +59 -0
  30. data/examples/npm-packages/runtime/on_mounted_demo/index.spec.js +50 -0
  31. data/examples/npm-packages/runtime/r_if_attribute_demo/index.html +34 -0
  32. data/examples/npm-packages/runtime/r_if_attribute_demo/index.rb +113 -0
  33. data/examples/npm-packages/runtime/r_if_attribute_demo/index.spec.js +140 -0
  34. data/examples/npm-packages/runtime/random_cocktail/index.html +27 -0
  35. data/examples/npm-packages/runtime/random_cocktail/index.rb +69 -0
  36. data/examples/npm-packages/runtime/random_cocktail/index.spec.js +101 -0
  37. data/examples/npm-packages/runtime/search_field/index.html +27 -0
  38. data/examples/npm-packages/runtime/search_field/index.rb +39 -0
  39. data/examples/npm-packages/runtime/search_field/index.spec.js +59 -0
  40. data/examples/npm-packages/runtime/todos/index.html +28 -0
  41. data/examples/npm-packages/runtime/todos/index.rb +239 -0
  42. data/examples/npm-packages/runtime/todos/index.spec.js +161 -0
  43. data/examples/npm-packages/runtime/todos/todos_repository.rb +23 -0
  44. data/examples/package.json +12 -0
  45. data/examples/src/counter/index.html +23 -0
  46. data/examples/src/counter/index.rb +60 -0
  47. data/lib/ruby_wasm_ui +1 -0
  48. data/lib/ruby_wasm_ui.rb +1 -0
  49. data/package-lock.json +100 -0
  50. data/package.json +32 -0
  51. data/packages/npm-packages/runtime/Gemfile +3 -0
  52. data/packages/npm-packages/runtime/Gemfile.lock +26 -0
  53. data/packages/npm-packages/runtime/README.md +5 -0
  54. data/packages/npm-packages/runtime/eslint.config.mjs +16 -0
  55. data/packages/npm-packages/runtime/package-lock.json +6668 -0
  56. data/packages/npm-packages/runtime/package.json +38 -0
  57. data/packages/npm-packages/runtime/rollup.config.mjs +89 -0
  58. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/component_spec.rb +416 -0
  59. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/dom/scheduler_spec.rb +98 -0
  60. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/nodes_equal_spec.rb +190 -0
  61. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_conditional_group_spec.rb +505 -0
  62. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_for_group_spec.rb +377 -0
  63. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_vdom_spec.rb +573 -0
  64. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/parser_spec.rb +627 -0
  65. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/arrays_spec.rb +228 -0
  66. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/objects_spec.rb +127 -0
  67. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/props_spec.rb +205 -0
  68. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/strings_spec.rb +107 -0
  69. data/packages/npm-packages/runtime/spec/spec_helper.rb +16 -0
  70. data/packages/npm-packages/runtime/src/__tests__/sample.test.js +5 -0
  71. data/packages/npm-packages/runtime/src/index.js +37 -0
  72. data/packages/npm-packages/runtime/src/ruby_wasm_ui/app.rb +53 -0
  73. data/packages/npm-packages/runtime/src/ruby_wasm_ui/component.rb +215 -0
  74. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dispatcher.rb +46 -0
  75. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/attributes.rb +105 -0
  76. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/destroy_dom.rb +63 -0
  77. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/events.rb +40 -0
  78. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/mount_dom.rb +108 -0
  79. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/patch_dom.rb +237 -0
  80. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/scheduler.rb +51 -0
  81. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom.rb +13 -0
  82. data/packages/npm-packages/runtime/src/ruby_wasm_ui/nodes_equal.rb +45 -0
  83. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_conditional_group.rb +150 -0
  84. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_for_group.rb +125 -0
  85. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_vdom.rb +220 -0
  86. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/parser.rb +134 -0
  87. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template.rb +11 -0
  88. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/arrays.rb +185 -0
  89. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/objects.rb +37 -0
  90. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/props.rb +25 -0
  91. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/strings.rb +19 -0
  92. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils.rb +11 -0
  93. data/packages/npm-packages/runtime/src/ruby_wasm_ui/vdom.rb +84 -0
  94. data/packages/npm-packages/runtime/src/ruby_wasm_ui/version.rb +5 -0
  95. data/packages/npm-packages/runtime/src/ruby_wasm_ui.rb +14 -0
  96. data/packages/npm-packages/runtime/vitest.config.js +8 -0
  97. data/playwright.config.js +78 -0
  98. data/sig/ruby_wasm_ui.rbs +4 -0
  99. metadata +168 -0
@@ -0,0 +1,37 @@
1
+ const userDefinedRubyScript = document.querySelector(
2
+ "script[type='text/ruby']"
3
+ );
4
+
5
+ const scriptElement = document.createElement("script");
6
+ scriptElement.src =
7
+ "https://cdn.jsdelivr.net/npm/@ruby/3.4-wasm-wasi@latest/dist/browser.script.iife.js";
8
+ scriptElement.setAttribute("defer", "");
9
+ userDefinedRubyScript.before(scriptElement);
10
+
11
+ function loadRubyScript(filePath) {
12
+ let baseUrl;
13
+ if (window.RUBY_WASM_UI_ENV === "production") {
14
+ baseUrl = "https://unpkg.com/ruby-wasm-ui@latest/dist";
15
+ } else {
16
+ baseUrl = "../../../../packages/npm-packages/runtime/dist";
17
+ }
18
+ let rubyScriptElement = document.createElement("script");
19
+ rubyScriptElement.type = "text/ruby";
20
+ rubyScriptElement.charset = "utf-8";
21
+ rubyScriptElement.src = `${baseUrl}/${filePath}`;
22
+ rubyScriptElement.setAttribute("defer", "");
23
+ userDefinedRubyScript.before(rubyScriptElement);
24
+ }
25
+
26
+ // Load ruby_wasm_ui.rb
27
+ loadRubyScript("ruby_wasm_ui.rb");
28
+
29
+ // Load all Ruby files in ruby_wasm_ui directory
30
+ const rubyFiles = window.RUBY_WASM_UI_FILES;
31
+ if (rubyFiles === undefined) {
32
+ throw new Error(
33
+ "RUBY_WASM_UI_FILES is not defined. This file should be built with rollup."
34
+ );
35
+ }
36
+
37
+ rubyFiles.forEach((file) => loadRubyScript(file));
@@ -0,0 +1,53 @@
1
+ module RubyWasmUi
2
+ class App
3
+ # @param root_component [Class]
4
+ # @param props [Hash]
5
+ # @return [App]
6
+ def self.create(root_component, props = {})
7
+ new(root_component, props)
8
+ end
9
+
10
+ # @param root_component [Class]
11
+ # @param props [Hash]
12
+ def initialize(root_component, props)
13
+ @root_component = root_component
14
+ @props = props
15
+ @parent_el = nil
16
+ @is_mounted = false
17
+ @vdom = nil
18
+ end
19
+
20
+ # @param parent_el [Element]
21
+ # @return [void]
22
+ def mount(parent_el)
23
+ if @is_mounted
24
+ raise "The application is already mounted"
25
+ end
26
+
27
+ @parent_el = parent_el
28
+ @vdom = RubyWasmUi::Vdom.h(@root_component, @props)
29
+ RubyWasmUi::Dom::MountDom.execute(@vdom, @parent_el)
30
+
31
+ @is_mounted = true
32
+ end
33
+
34
+ # @return [void]
35
+ def unmount
36
+ unless @is_mounted
37
+ raise "The application is not mounted"
38
+ end
39
+
40
+ RubyWasmUi::Dom::DestroyDom.execute(@vdom)
41
+ reset
42
+ end
43
+
44
+ private
45
+
46
+ # @return [void]
47
+ def reset
48
+ @parent_el = nil
49
+ @is_mounted = false
50
+ @vdom = nil
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,215 @@
1
+ module RubyWasmUi
2
+
3
+ def self.empty_proc
4
+ -> { }
5
+ end
6
+
7
+ # Define a new component class
8
+ # @param template [Proc] The template function
9
+ # @param state [Proc, nil] The state function
10
+ # @param methods [Hash] Additional methods to add to the component
11
+ # @return [Class] The new component class
12
+ def self.define_component(template:, state: nil, on_mounted: empty_proc, on_unmounted: empty_proc, methods: {})
13
+ Class.new(Component) do
14
+ self.class_variable_set(:@@state, state)
15
+ self.class_variable_set(:@@template, template)
16
+ self.class_variable_set(:@@on_mounted, on_mounted)
17
+ self.class_variable_set(:@@on_unmounted, on_unmounted)
18
+
19
+ # Add methods to the component
20
+ methods.each do |method_name, method_proc|
21
+ # Check if method already exists
22
+ if method_defined?(method_name) || private_method_defined?(method_name)
23
+ raise "Method \"#{method_name}()\" already exists in the component."
24
+ end
25
+
26
+ # Define the method dynamically
27
+ define_method(method_name, method_proc)
28
+ end
29
+ end
30
+ end
31
+
32
+ class Component
33
+ def initialize(props = {}, event_handlers = {}, parent_component = nil)
34
+ @props = props
35
+ @is_mounted = false
36
+ @vdom = nil
37
+ @host_el = nil
38
+ @state = initialize_state
39
+ @template = self.class.class_variable_get(:@@template)
40
+ @event_handlers = event_handlers
41
+ @parent_component = parent_component
42
+ @dispatcher = RubyWasmUi::Dispatcher.new
43
+ @subscriptions = []
44
+ end
45
+
46
+ attr_reader :state, :props
47
+
48
+ def on_mounted
49
+ on_mounted_proc = self.class.class_variable_get(:@@on_mounted)
50
+ if on_mounted_proc.arity == 0
51
+ instance_exec(&on_mounted_proc)
52
+ else
53
+ on_mounted_proc.call(self)
54
+ end
55
+ end
56
+
57
+ def on_unmounted
58
+ on_unmounted_proc = self.class.class_variable_get(:@@on_unmounted)
59
+ if on_unmounted_proc.arity == 0
60
+ instance_exec(&on_unmounted_proc)
61
+ else
62
+ on_unmounted_proc.call(self)
63
+ end
64
+ end
65
+
66
+ # Get VDOM elements
67
+ # @return [Array<JS::Object>]
68
+ def elements
69
+ return [] if @vdom.nil?
70
+
71
+ if @vdom.type == RubyWasmUi::Vdom::DOM_TYPES[:FRAGMENT]
72
+ RubyWasmUi::Vdom.extract_children(@vdom).flat_map do |child|
73
+ if child.type == RubyWasmUi::Vdom::DOM_TYPES[:COMPONENT]
74
+ child.component.elements
75
+ else
76
+ [child.el]
77
+ end
78
+ end
79
+ else
80
+ [@vdom.el]
81
+ end
82
+ end
83
+
84
+ # Get the first element
85
+ # @return [JS::Object, nil]
86
+ def first_element
87
+ elements[0]
88
+ end
89
+
90
+ # Get offset within host element
91
+ # @return [Integer]
92
+ def offset
93
+ if @vdom.type == RubyWasmUi::Vdom::DOM_TYPES[:FRAGMENT]
94
+ children = @host_el[:children].to_a
95
+ children.index(first_element) || 0
96
+ else
97
+ 0
98
+ end
99
+ end
100
+
101
+ # Update state
102
+ # @param new_state [Hash] New state
103
+ def update_state(new_state)
104
+ merged_state = @state.merge(new_state)
105
+ return if @state == merged_state
106
+
107
+ @state = merged_state
108
+ patch
109
+ end
110
+
111
+ # Update props
112
+ # @param new_props [Hash] New props
113
+ def update_props(new_props)
114
+ merged_props = @props.merge(new_props)
115
+ return if @props == merged_props
116
+
117
+ @props = merged_props
118
+ patch
119
+ end
120
+
121
+ # @return [RubyWasmUi::Vdom]
122
+ def template
123
+ if @template.arity == 0
124
+ instance_exec(&@template)
125
+ else
126
+ @template.call(self)
127
+ end
128
+ end
129
+
130
+ # Mount component
131
+ # @param host_el [JS::Object] Host element
132
+ # @param index [Integer, nil] Insert position
133
+ def mount(host_el, index = nil)
134
+ raise "Component is already mounted" if @is_mounted
135
+
136
+ @vdom = template
137
+ RubyWasmUi::Dom::MountDom.execute(@vdom, host_el, index, self)
138
+ wire_event_handlers
139
+
140
+ @host_el = host_el
141
+ @is_mounted = true
142
+ end
143
+
144
+ # Unmount component
145
+ def unmount
146
+ raise "Component is not mounted" unless @is_mounted
147
+
148
+ RubyWasmUi::Dom::DestroyDom.execute(@vdom)
149
+
150
+ # Safely handle subscriptions, filtering out nil values
151
+ @subscriptions.compact.each { |unsubscription| unsubscription.call }
152
+
153
+ @vdom = nil
154
+ @host_el = nil
155
+ @is_mounted = false
156
+ @subscriptions = []
157
+ end
158
+
159
+ # Emit an event
160
+ # @param event_name [String] Event name
161
+ # @param payload [Object, nil] Event payload
162
+ def emit(event_name, payload = nil)
163
+ @dispatcher.dispatch(event_name, payload) if @dispatcher
164
+ end
165
+
166
+ private
167
+
168
+ # Patch VDOM
169
+ def patch
170
+ raise "Component is not mounted" unless @is_mounted
171
+
172
+ vdom = template
173
+ @vdom = RubyWasmUi::Dom::PatchDom.execute(@vdom, vdom, @host_el, self)
174
+ end
175
+
176
+ # Wire event handlers
177
+ def wire_event_handlers
178
+ @subscriptions = @event_handlers.map do |event_name, handler|
179
+ wire_event_handler(event_name, handler)
180
+ end
181
+ end
182
+
183
+ # Wire a single event handler
184
+ # @param event_name [String] Event name
185
+ # @param handler [Proc] Event handler
186
+ # @return [Object] Subscription object
187
+ def wire_event_handler(event_name, handler)
188
+ handler_proc = if @parent_component && handler.arity == 1
189
+ ->(payload) { @parent_component.instance_exec(payload, &handler) }
190
+ elsif @parent_component && handler.arity == 0
191
+ ->(payload) { @parent_component.instance_exec(&handler) }
192
+ elsif handler.arity == 1
193
+ ->(payload) { handler.call(payload) }
194
+ else
195
+ ->(payload) { handler.call }
196
+ end
197
+
198
+ subscription = @dispatcher.subscribe(event_name, handler_proc)
199
+
200
+ # Ensure we always return a callable unsubscription function
201
+ subscription || -> { puts "Warning: No-op unsubscription for #{event_name}" }
202
+ end
203
+
204
+ # Initialize component state based on state proc
205
+ # @return [Hash] Initial state
206
+ def initialize_state
207
+ state_proc = self.class.class_variable_get(:@@state)
208
+ if state_proc
209
+ state_proc.arity == 0 ? state_proc.call : state_proc.call(@props)
210
+ else
211
+ {}
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,46 @@
1
+ module RubyWasmUi
2
+ class Dispatcher
3
+ def initialize
4
+ @subs = {}
5
+ @after_handlers = []
6
+ end
7
+
8
+ # @param command_name [String]
9
+ def subscribe(command_name, handler)
10
+ @subs[command_name] ||= []
11
+ handlers = @subs[command_name]
12
+
13
+ return -> {} if handlers.include?(handler)
14
+
15
+ handlers << handler
16
+
17
+ -> {
18
+ idx = handlers.index(handler)
19
+ handlers.delete_at(idx) if idx
20
+ }
21
+ end
22
+
23
+ # @param handler [Proc]
24
+ def after_every_command(handler)
25
+ @after_handlers << handler
26
+
27
+ -> {
28
+ idx = @after_handlers.index(handler)
29
+ @after_handlers.delete_at(idx) if idx
30
+ }
31
+ end
32
+
33
+ # @param command_name [String]
34
+ # @param payload [Object]
35
+ def dispatch(command_name, payload)
36
+ command_name_sym = command_name.to_sym
37
+ if @subs.key?(command_name_sym)
38
+ @subs[command_name_sym].each { |handler| handler.call(payload) }
39
+ else
40
+ warn "No handlers for command: #{command_name}"
41
+ end
42
+
43
+ @after_handlers.each(&:call)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,105 @@
1
+ module RubyWasmUi
2
+ module Dom
3
+ module Attributes
4
+ module_function
5
+
6
+ # @param element [JS::Object]
7
+ # @param attrs [Hash]
8
+ def set_attributes(element, attrs)
9
+ class_name = attrs[:class]
10
+ style = attrs[:style]
11
+ other_attrs = attrs.reject { |key, _| [:class, :style].include?(key) }
12
+
13
+ if class_name
14
+ set_class(element, class_name)
15
+ end
16
+
17
+ if style
18
+ parse_style(style).each do |name, value|
19
+ set_style(element, name, value)
20
+ end
21
+ end
22
+
23
+ other_attrs.each do |name, value|
24
+ set_attribute(element, name, value)
25
+ end
26
+ end
27
+
28
+ # @param element [JS::Object]
29
+ # @param class_name [String, Array]
30
+ def set_class(element, class_name)
31
+ element[:className] = ""
32
+
33
+ case class_name
34
+ when String
35
+ element[:className] = class_name
36
+ when Array
37
+ element[:classList].add(*class_name)
38
+ end
39
+ end
40
+
41
+ # @param element [JS::Object]
42
+ # @param name [String, Symbol]
43
+ # @param value [String]
44
+ def set_style(element, name, value)
45
+ element[:style][name] = value
46
+ end
47
+
48
+ # @param element [JS::Object]
49
+ # @param name [String, Symbol]
50
+ def remove_style(element, name)
51
+ element[:style][name] = nil
52
+ end
53
+
54
+ # @param element [JS::Object]
55
+ # @param name [String, Symbol]
56
+ # @param value [String, nil]
57
+ def set_attribute(element, name, value)
58
+ if value.nil?
59
+ remove_attribute(element, name)
60
+ elsif name.to_s.start_with?("data-")
61
+ element.setAttribute(name, value)
62
+ elsif name.to_s == "for"
63
+ element[:htmlFor] = value
64
+ else
65
+ element[name] = value
66
+ end
67
+ end
68
+
69
+ # @param element [JS::Object]
70
+ # @param name [String, Symbol]
71
+ def remove_attribute(element, name)
72
+ element[name] = nil
73
+ element.removeAttribute(name)
74
+ end
75
+
76
+ # Parse CSS style string into hash
77
+ # @param style_string [String] CSS style string like "color: red; margin: 10px;"
78
+ # @return [Hash] Hash with camelCase property names as keys
79
+ def parse_style(style)
80
+ return {} if style.nil?
81
+
82
+ if style.is_a?(Hash)
83
+ return style
84
+ end
85
+
86
+ result = {}
87
+ style.split(';').each do |style_rule|
88
+ next if style_rule.strip.empty?
89
+
90
+ property, value = style_rule.split(':', 2)
91
+ next unless property && value
92
+
93
+ property = property.strip
94
+ value = value.strip
95
+
96
+ # Convert kebab-case to camelCase for JavaScript style properties
97
+ camel_case_property = property.gsub(/-([a-z])/) { $1.upcase }
98
+
99
+ result[camel_case_property] = value
100
+ end
101
+ result
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,63 @@
1
+ module RubyWasmUi
2
+ module Dom
3
+ module DestroyDom
4
+ # @param vdom [RubyWasmUi::Vdom]
5
+ def execute(vdom)
6
+ case vdom.type
7
+ when RubyWasmUi::Vdom::DOM_TYPES[:TEXT]
8
+ remove_text_node(vdom)
9
+ when RubyWasmUi::Vdom::DOM_TYPES[:ELEMENT]
10
+ remove_element_node(vdom)
11
+ when RubyWasmUi::Vdom::DOM_TYPES[:FRAGMENT]
12
+ remove_fragment_nodes(vdom)
13
+ when RubyWasmUi::Vdom::DOM_TYPES[:COMPONENT]
14
+ remove_component_node(vdom)
15
+ else
16
+ raise "Can't destroy DOM of type: #{vdom.type}"
17
+ end
18
+
19
+ vdom.el = nil
20
+ end
21
+
22
+ module_function :execute
23
+
24
+ private
25
+
26
+ # @param vdom [RubyWasmUi::Vdom]
27
+ # @return [void]
28
+ def self.remove_text_node(vdom)
29
+ vdom.el&.remove
30
+ end
31
+
32
+ # @param vdom [RubyWasmUi::Vdom]
33
+ # @return [void]
34
+ def self.remove_element_node(vdom)
35
+ el = vdom.el
36
+ children = vdom.children
37
+ listeners = vdom.listeners
38
+
39
+ el&.remove
40
+ children&.each { |child| execute(child) }
41
+
42
+ if listeners
43
+ Events.remove_event_listeners(listeners, el)
44
+ vdom.listeners = nil
45
+ end
46
+ end
47
+
48
+ # @param vdom [RubyWasmUi::Vdom]
49
+ # @return [void]
50
+ def self.remove_fragment_nodes(vdom)
51
+ children = vdom.children
52
+ children&.each { |child| execute(child) }
53
+ end
54
+
55
+ # @param vdom [RubyWasmUi::Vdom]
56
+ # @return [void]
57
+ def self.remove_component_node(vdom)
58
+ vdom.component.unmount
59
+ RubyWasmUi::Dom::Scheduler.enqueue_job(-> { vdom.component.on_unmounted })
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,40 @@
1
+ module RubyWasmUi
2
+ module Dom
3
+ module Events
4
+ module_function
5
+
6
+ # @param event_name [String]
7
+ # @param handler [Proc]
8
+ # @param element [JS::Object]
9
+ # @param host_component [Object, nil]
10
+ # @return [Proc]
11
+ def add_event_listener(event_name, handler, element, host_component = nil)
12
+ if host_component
13
+ # Same as JavaScript's handler.apply(hostComponent, arguments)
14
+ call_handler = JS.try_convert(->(event) {
15
+ handler.arity == 0 ? host_component.instance_exec(&handler) : host_component.instance_exec(event, &handler)
16
+ })
17
+ else
18
+ call_handler = JS.try_convert(->(event) { handler.arity == 0 ? handler.call : handler.call(event) })
19
+ end
20
+ element.call(:addEventListener, event_name.to_s, call_handler)
21
+ call_handler
22
+ end
23
+
24
+ def add_event_listeners(element, listeners = {}, host_component = nil)
25
+ listeners.each do |event_name, handler|
26
+ add_event_listener(event_name, handler, element, host_component)
27
+ end
28
+ listeners
29
+ end
30
+
31
+ # @param listeners [Hash]
32
+ # @param element [JS::Object]
33
+ def remove_event_listeners(listeners = {}, element)
34
+ listeners.each do |event_name, handler|
35
+ element.call(:removeEventListener, event_name.to_s, handler)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,108 @@
1
+ module RubyWasmUi
2
+ module Dom
3
+ module MountDom
4
+ # @param vdom [RubyWasmUi::Vdom]
5
+ # @param parent_el [JS::Object]
6
+ # @param index [Integer, nil] Index position to insert at
7
+ # @param hostComponent [RubyWasmUi::Component, nil] Host component
8
+ def execute(vdom, parent_el, index = nil, hostComponent = nil)
9
+ case vdom.type
10
+ when RubyWasmUi::Vdom::DOM_TYPES[:TEXT]
11
+ create_text_node(vdom, parent_el, index)
12
+ when RubyWasmUi::Vdom::DOM_TYPES[:ELEMENT]
13
+ create_element_node(vdom, parent_el, index, hostComponent)
14
+ when RubyWasmUi::Vdom::DOM_TYPES[:FRAGMENT]
15
+ create_fragment_nodes(vdom, parent_el, index, hostComponent)
16
+ when RubyWasmUi::Vdom::DOM_TYPES[:COMPONENT]
17
+ create_component_node(vdom, parent_el, index, hostComponent)
18
+ RubyWasmUi::Dom::Scheduler.enqueue_job(-> { vdom.component.on_mounted })
19
+ else
20
+ raise "Can't mount DOM of type: #{vdom.type}"
21
+ end
22
+ end
23
+
24
+ # @param el [JS::Object] Element to insert
25
+ # @param parent_el [JS::Object] Parent element
26
+ # @param index [Integer, nil] Index position to insert at
27
+ def insert(el, parent_el, index)
28
+ # If index is nil or undefined, simply append to the end
29
+ if index.nil?
30
+ parent_el.append(el)
31
+ return
32
+ end
33
+
34
+ # If index is negative, raise an error
35
+ if index < 0
36
+ raise "Index must be a positive integer, got #{index}"
37
+ end
38
+
39
+ children = parent_el[:childNodes]
40
+
41
+ # If index is greater than or equal to the number of children, append to the end
42
+ if index >= children[:length].to_i # to_i is necessary because length is a JS::Number
43
+ parent_el.append(el)
44
+ else
45
+ # Insert at the specified index position
46
+ parent_el.insertBefore(el, children[index])
47
+ end
48
+ end
49
+
50
+ module_function :execute, :insert
51
+
52
+ private
53
+
54
+ def self.create_text_node(vdom, parent_el, index)
55
+ text_node = JS.global[:document].createTextNode(vdom.value)
56
+ vdom.el = text_node
57
+ insert(text_node, parent_el, index)
58
+ end
59
+
60
+ def self.create_element_node(vdom, parent_el, index, hostComponent)
61
+ element = JS.global[:document].createElement(vdom.tag)
62
+ add_props(element, vdom, hostComponent)
63
+ vdom.el = element
64
+
65
+ vdom.children&.each do |child|
66
+ execute(child, element, nil, hostComponent)
67
+ end
68
+
69
+ insert(element, parent_el, index)
70
+ end
71
+
72
+ # @param el [JS::Object] Element to add props to
73
+ # @param vdom [RubyWasmUi::Vdom] VDOM node
74
+ # @param hostComponent [RubyWasmUi::Component, nil] Host component
75
+ def self.add_props(el, vdom, hostComponent)
76
+ extracted = RubyWasmUi::Utils::Props.extract_props_and_events(vdom)
77
+ events = extracted[:events]
78
+ attrs = extracted[:props]
79
+
80
+ vdom.listeners = Events.add_event_listeners(el, events, hostComponent) if events
81
+ RubyWasmUi::Dom::Attributes.set_attributes(el, attrs) if attrs.any?
82
+ end
83
+
84
+ def self.create_fragment_nodes(vdom, parent_el, index, hostComponent)
85
+ vdom.el = parent_el
86
+
87
+ vdom.children&.each_with_index do |child, i|
88
+ execute(child, parent_el, index ? index + i : nil, hostComponent)
89
+ end
90
+ end
91
+
92
+ # @param vdom [RubyWasmUi::Vdom]
93
+ # @param parent_el [JS::Object] Parent element
94
+ # @param index [Integer, nil] Index position to insert at
95
+ # @param host_component [RubyWasmUi::Component, nil] Host component
96
+ # @return [void]
97
+ def self.create_component_node(vdom, parent_el, index, host_component)
98
+ component_class = vdom.tag
99
+ extracted = RubyWasmUi::Utils::Props.extract_props_and_events(vdom)
100
+ component = component_class.new(extracted[:props], extracted[:events], host_component)
101
+
102
+ component.mount(parent_el, index)
103
+ vdom.component = component
104
+ vdom.el = component.first_element
105
+ end
106
+ end
107
+ end
108
+ end