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.
- checksums.yaml +7 -0
- data/.cursor/rules/ruby_comments.mdc +29 -0
- data/.github/workflows/playwright.yml +74 -0
- data/.github/workflows/rspec.yml +33 -0
- data/.node-version +1 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/Rakefile +4 -0
- data/docs/conditional-rendering.md +119 -0
- data/docs/lifecycle-hooks.md +75 -0
- data/docs/list-rendering.md +51 -0
- data/examples/Gemfile +5 -0
- data/examples/Gemfile.lock +41 -0
- data/examples/Makefile +15 -0
- data/examples/npm-packages/runtime/counter/index.html +28 -0
- data/examples/npm-packages/runtime/counter/index.rb +62 -0
- data/examples/npm-packages/runtime/counter/index.spec.js +42 -0
- data/examples/npm-packages/runtime/hello/index.html +28 -0
- data/examples/npm-packages/runtime/hello/index.rb +29 -0
- data/examples/npm-packages/runtime/hello/index.spec.js +53 -0
- data/examples/npm-packages/runtime/input/index.html +28 -0
- data/examples/npm-packages/runtime/input/index.rb +46 -0
- data/examples/npm-packages/runtime/input/index.spec.js +58 -0
- data/examples/npm-packages/runtime/list/index.html +27 -0
- data/examples/npm-packages/runtime/list/index.rb +33 -0
- data/examples/npm-packages/runtime/list/index.spec.js +46 -0
- data/examples/npm-packages/runtime/on_mounted_demo/index.html +40 -0
- data/examples/npm-packages/runtime/on_mounted_demo/index.rb +59 -0
- data/examples/npm-packages/runtime/on_mounted_demo/index.spec.js +50 -0
- data/examples/npm-packages/runtime/r_if_attribute_demo/index.html +34 -0
- data/examples/npm-packages/runtime/r_if_attribute_demo/index.rb +113 -0
- data/examples/npm-packages/runtime/r_if_attribute_demo/index.spec.js +140 -0
- data/examples/npm-packages/runtime/random_cocktail/index.html +27 -0
- data/examples/npm-packages/runtime/random_cocktail/index.rb +69 -0
- data/examples/npm-packages/runtime/random_cocktail/index.spec.js +101 -0
- data/examples/npm-packages/runtime/search_field/index.html +27 -0
- data/examples/npm-packages/runtime/search_field/index.rb +39 -0
- data/examples/npm-packages/runtime/search_field/index.spec.js +59 -0
- data/examples/npm-packages/runtime/todos/index.html +28 -0
- data/examples/npm-packages/runtime/todos/index.rb +239 -0
- data/examples/npm-packages/runtime/todos/index.spec.js +161 -0
- data/examples/npm-packages/runtime/todos/todos_repository.rb +23 -0
- data/examples/package.json +12 -0
- data/examples/src/counter/index.html +23 -0
- data/examples/src/counter/index.rb +60 -0
- data/lib/ruby_wasm_ui +1 -0
- data/lib/ruby_wasm_ui.rb +1 -0
- data/package-lock.json +100 -0
- data/package.json +32 -0
- data/packages/npm-packages/runtime/Gemfile +3 -0
- data/packages/npm-packages/runtime/Gemfile.lock +26 -0
- data/packages/npm-packages/runtime/README.md +5 -0
- data/packages/npm-packages/runtime/eslint.config.mjs +16 -0
- data/packages/npm-packages/runtime/package-lock.json +6668 -0
- data/packages/npm-packages/runtime/package.json +38 -0
- data/packages/npm-packages/runtime/rollup.config.mjs +89 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/component_spec.rb +416 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/dom/scheduler_spec.rb +98 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/nodes_equal_spec.rb +190 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_conditional_group_spec.rb +505 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_for_group_spec.rb +377 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_vdom_spec.rb +573 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/parser_spec.rb +627 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/arrays_spec.rb +228 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/objects_spec.rb +127 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/props_spec.rb +205 -0
- data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/strings_spec.rb +107 -0
- data/packages/npm-packages/runtime/spec/spec_helper.rb +16 -0
- data/packages/npm-packages/runtime/src/__tests__/sample.test.js +5 -0
- data/packages/npm-packages/runtime/src/index.js +37 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/app.rb +53 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/component.rb +215 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dispatcher.rb +46 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/attributes.rb +105 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/destroy_dom.rb +63 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/events.rb +40 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/mount_dom.rb +108 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/patch_dom.rb +237 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/scheduler.rb +51 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom.rb +13 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/nodes_equal.rb +45 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_conditional_group.rb +150 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_for_group.rb +125 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_vdom.rb +220 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/parser.rb +134 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/template.rb +11 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/arrays.rb +185 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/objects.rb +37 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/props.rb +25 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/strings.rb +19 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils.rb +11 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/vdom.rb +84 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui/version.rb +5 -0
- data/packages/npm-packages/runtime/src/ruby_wasm_ui.rb +14 -0
- data/packages/npm-packages/runtime/vitest.config.js +8 -0
- data/playwright.config.js +78 -0
- data/sig/ruby_wasm_ui.rbs +4 -0
- 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
|