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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmUi
4
+ module Template
5
+ module BuildVdom
6
+ module_function
7
+
8
+ # @param elements [JS.Array]
9
+ # @return [String]
10
+ def build(elements)
11
+ vdom = []
12
+ i = 0
13
+ elements_length = elements[:length].to_i
14
+
15
+ while i < elements_length
16
+ element = elements[i]
17
+
18
+ # text node
19
+ if element[:nodeType] == JS.global[:Node][:TEXT_NODE]
20
+ text_result = parse_text_node(element)
21
+ vdom << text_result if text_result
22
+ i += 1
23
+ next
24
+ end
25
+
26
+ tag_name = element[:tagName].to_s.downcase
27
+
28
+ # fragment node (including div elements with data-template attribute)
29
+ if element[:nodeType] == JS.global[:Node][:ELEMENT_NODE] && (tag_name == 'template' || (tag_name == 'div' && has_data_template_attribute?(element)))
30
+ # Check for conditional attributes on template
31
+ if RubyWasmUi::Template::BuildConditionalGroup.has_conditional_attribute?(element)
32
+ # Process conditional group (r-if, r-elsif, r-else)
33
+ conditional_group, next_index = RubyWasmUi::Template::BuildConditionalGroup.build_conditional_group(elements, i)
34
+ vdom << conditional_group
35
+ i = next_index
36
+ else
37
+ vdom << build_fragment(element, tag_name)
38
+ i += 1
39
+ end
40
+ next
41
+ end
42
+
43
+ # element node (including components)
44
+ if element[:nodeType] == JS.global[:Node][:ELEMENT_NODE]
45
+ # Check for conditional attributes on all elements (including components)
46
+ if RubyWasmUi::Template::BuildConditionalGroup.has_conditional_attribute?(element)
47
+ # Process conditional group (r-if, r-elsif, r-else)
48
+ conditional_group, next_index = RubyWasmUi::Template::BuildConditionalGroup.build_conditional_group(elements, i)
49
+ vdom << conditional_group
50
+ i = next_index
51
+ # Check for r-for attribute on all elements (including components)
52
+ elsif RubyWasmUi::Template::BuildForGroup.has_for_attribute?(element)
53
+ # Process r-for loop - the result is a map expression that returns an array
54
+ for_loop = RubyWasmUi::Template::BuildForGroup.build_for_loop(element)
55
+ if for_loop && !for_loop.empty?
56
+ # Wrap the map result with splat operator to expand the array
57
+ vdom << "*#{for_loop}"
58
+ end
59
+ i += 1
60
+ else
61
+ # Handle components and regular elements
62
+ if is_component?(tag_name)
63
+ vdom << build_component(element, tag_name)
64
+ else
65
+ vdom << build_element(element, tag_name)
66
+ end
67
+ i += 1
68
+ end
69
+ next
70
+ end
71
+
72
+ i += 1
73
+ end
74
+
75
+ vdom.compact.join(',')
76
+ end
77
+
78
+ # parse text node
79
+ # ex) "test" -> "test"
80
+ # ex) "test {state[:count]}" -> "test #{state[:count]}"
81
+ # ex) "test {state[:count] + 1}" -> "test #{state[:count] + 1}"
82
+ # ex) "test {state[:count]} test" -> "test #{state[:count]} test"
83
+ # ex) "test {state[:count]} test {state[:count]} test" -> "test #{state[:count]} test #{state[:count]} test"
84
+ # @param element [JS.Object]
85
+ # @return [String]
86
+ def parse_text_node(element)
87
+ value = element[:nodeValue].to_s.chomp.strip
88
+
89
+ return nil if value.empty?
90
+
91
+ # Split the text by embedded script pattern and process each part
92
+ # Regular expression explanation:
93
+ # ( : Start capture group (this ensures the pattern itself is included in the result)
94
+ # \{ : Match an opening curly brace (escaped because { is special in regex)
95
+ # [^}]+ : Match one or more characters that are not a closing curly brace
96
+ # \} : Match a closing curly brace (escaped because } is special in regex)
97
+ # ) : End capture group
98
+ # Example:
99
+ # Input: "hello {state[:count]} world"
100
+ # Output: ["hello ", "{state[:count]}", " world"]
101
+ parts = value.split(/(\{[^}]+\})/)
102
+ processed_parts = parts.map do |part|
103
+ if embed_script?(part)
104
+ "\#{#{get_embed_script(part)}}"
105
+ else
106
+ part
107
+ end
108
+ end
109
+
110
+ # Join all parts and wrap in double quotes
111
+ %("#{processed_parts.join}")
112
+ end
113
+
114
+ # Parse attributes array
115
+ # @param attributes [Array]
116
+ # @return [String]
117
+ def parse_attributes(attributes)
118
+ attributes_str = []
119
+
120
+ attributes.each do |attribute|
121
+ key = attribute[:name].to_s
122
+ value = attribute[:value].to_s
123
+
124
+ if embed_script?(value)
125
+ # Special handling for 'on' attribute to preserve hash structure
126
+ if key == 'on'
127
+ attributes_str << ":#{key} => #{value}"
128
+ else
129
+ attributes_str << ":#{key} => #{get_embed_script(value)}"
130
+ end
131
+ next
132
+ end
133
+
134
+ attributes_str << ":#{key} => '#{value}'"
135
+ end
136
+ attributes_str.join(', ')
137
+ end
138
+
139
+ # @param tag_name [String]
140
+ # @return [Boolean]
141
+ def is_component?(tag_name)
142
+ # Component tags start with letter but exclude standard HTML elements
143
+ return false unless tag_name.match?(/^[a-z]/)
144
+
145
+ # Use the standard HTML elements list from Parser module
146
+ !RubyWasmUi::Template::Parser::STANDARD_HTML_ELEMENTS.include?(tag_name)
147
+ end
148
+
149
+ # @param doc [String]
150
+ # @return [Boolean]
151
+ def embed_script?(doc)
152
+ doc.match?(/\{.+\}/)
153
+ end
154
+
155
+ # get value from embed script
156
+ # ex) Count: {component.state[:count]} -> Count: component.state[:count]
157
+ # @param script [String]
158
+ # @return [String]
159
+ def get_embed_script(script)
160
+ script.gsub(/\{(.+)\}/) { ::Regexp.last_match(1) }
161
+ end
162
+
163
+ # Check if element has data-template attribute
164
+ # @param element [JS.Object]
165
+ # @return [Boolean]
166
+ def has_data_template_attribute?(element)
167
+ return false unless element[:attributes]
168
+
169
+ length = element[:attributes][:length].to_i
170
+ length.times do |i|
171
+ attribute = element[:attributes][i]
172
+ key = attribute[:name].to_s
173
+ return true if key == 'data-template'
174
+ end
175
+ false
176
+ end
177
+
178
+ # Build fragment or div element with data-template attribute
179
+ # @param element [JS.Object]
180
+ # @param tag_name [String]
181
+ # @return [String]
182
+ def build_fragment(element, tag_name)
183
+ # div elements with data-template don't have content property, use childNodes directly
184
+ if tag_name == 'template' && element[:content]
185
+ content_nodes = element[:content][:childNodes]
186
+ else
187
+ content_nodes = element[:childNodes]
188
+ end
189
+ children = build(content_nodes)
190
+ "RubyWasmUi::Vdom.h_fragment([#{children}])"
191
+ end
192
+
193
+ # Build component element
194
+ # @param element [JS.Object]
195
+ # @param tag_name [String]
196
+ # @param filtered_attributes [Array]
197
+ # @return [String]
198
+ def build_component(element, tag_name, filtered_attributes = nil)
199
+ attributes_str = parse_attributes(filtered_attributes || element[:attributes].to_a)
200
+ children = build(element[:childNodes])
201
+
202
+ # Convert kebab-case to PascalCase for component name
203
+ component_name = tag_name.split('-').map(&:capitalize).join
204
+ "RubyWasmUi::Vdom.h(#{component_name}, {#{attributes_str}}, [#{children}])"
205
+ end
206
+
207
+ # Build regular HTML element
208
+ # @param element [JS.Object]
209
+ # @param tag_name [String]
210
+ # @param filtered_attributes [Array]
211
+ # @return [String]
212
+ def build_element(element, tag_name, filtered_attributes = nil)
213
+ attributes_str = parse_attributes(filtered_attributes || element[:attributes].to_a)
214
+ children = build(element[:childNodes])
215
+
216
+ "RubyWasmUi::Vdom.h('#{tag_name}', {#{attributes_str}}, [#{children}])"
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmUi
4
+ module Template
5
+ module Parser
6
+ # Standard HTML elements that should not be treated as custom components
7
+ STANDARD_HTML_ELEMENTS = %w[
8
+ a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup
9
+ data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6
10
+ head header hr html i iframe img input ins kbd label legend li link main map mark meta meter nav noscript object ol
11
+ optgroup option output p param picture pre progress q rp rt ruby s samp script section select small source span
12
+ strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var video wbr
13
+ ].freeze
14
+
15
+ module_function
16
+
17
+ # @param template [String]
18
+ # @param binding [Binding]
19
+ # @return [RubyWasmUi::Vdom]
20
+ def parse_and_eval(template, binding)
21
+ vdom_code = parse(template)
22
+
23
+ # If the code contains multiple top-level expressions, wrap them in a fragment
24
+ if vdom_code.include?('end,') || (vdom_code.count(',') > 0 && !vdom_code.start_with?('['))
25
+ vdom_code = "RubyWasmUi::Vdom.h_fragment([#{vdom_code}])"
26
+ end
27
+
28
+ eval(vdom_code, binding)
29
+ end
30
+
31
+ # @param template [String]
32
+ # @return [String]
33
+ def parse(template)
34
+ # Replace <template> with <div data-template> to work around DOMParser limitations
35
+ processed_template = preprocess_template_tag(template)
36
+
37
+ # Convert PascalCase component names to kebab-case
38
+ processed_template = preprocess_pascal_case_component_name(processed_template)
39
+
40
+ # Preprocess self-closing custom element tags
41
+ processed_template = preprocess_self_closing_tags(processed_template)
42
+
43
+ parser = JS.eval('return new DOMParser()')
44
+ document = parser.call(:parseFromString, JS.try_convert(processed_template), 'text/html')
45
+ elements = document.getElementsByTagName('body')[0][:childNodes]
46
+
47
+ RubyWasmUi::Template::BuildVdom.build(elements)
48
+ end
49
+
50
+ # Convert PascalCase component names to kebab-case in template
51
+ # @param template [String]
52
+ # @return [String]
53
+ def preprocess_pascal_case_component_name(template)
54
+ processed_template = template.dup
55
+
56
+ # Convert opening tags (e.g., <ButtonComponent> -> <button-component>)
57
+ # Pattern explanation:
58
+ # - <: Matches the opening angle bracket
59
+ # - ([A-Z][a-zA-Z0-9]*): Captures PascalCase component name
60
+ # - [A-Z]: First letter must be uppercase
61
+ # - [a-zA-Z0-9]*: Followed by any number of letters or numbers
62
+ # - (\s|>|\/): Captures the delimiter after the component name
63
+ # - \s: Whitespace for attributes
64
+ # - >: End of opening tag
65
+ # - \/: Self-closing tag
66
+ # - /i: Case-insensitive matching
67
+ processed_template = processed_template.gsub(/<([A-Z][a-zA-Z0-9]*)(\s|>|\/)/i) do
68
+ component_name = ::Regexp.last_match(1) # e.g., "ButtonComponent"
69
+ delimiter = ::Regexp.last_match(2) # e.g., " " or ">" or "/"
70
+
71
+ # Convert component name to kebab-case:
72
+ # 1. Insert hyphen before capital letters: ButtonComponent -> Button-Component
73
+ # 2. Convert to lowercase: Button-Component -> button-component
74
+ kebab_name = component_name.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
75
+
76
+ "<#{kebab_name}#{delimiter}"
77
+ end
78
+
79
+ # Convert closing tags (e.g., </ButtonComponent> -> </button-component>)
80
+ # Pattern explanation:
81
+ # - <\/: Matches the closing tag prefix
82
+ # - ([A-Z][a-zA-Z0-9]*): Captures PascalCase component name (same as above)
83
+ # - >: Matches the closing angle bracket
84
+ # - /i: Case-insensitive matching
85
+ processed_template = processed_template.gsub(/<\/([A-Z][a-zA-Z0-9]*)>/i) do
86
+ component_name = ::Regexp.last_match(1) # e.g., "ButtonComponent"
87
+
88
+ # Convert component name to kebab-case (same process as above)
89
+ kebab_name = component_name.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
90
+
91
+ "</#{kebab_name}>"
92
+ end
93
+
94
+ processed_template
95
+ end
96
+
97
+ # Replace <template> tags with <div data-template> to work around DOMParser limitations
98
+ # @param template [String]
99
+ # @return [String]
100
+ def preprocess_template_tag(template)
101
+ processed_template = template.dup
102
+
103
+ # Replace <template> with attributes (e.g., <template class="container">)
104
+ processed_template = processed_template.gsub(/<template\s/, '<div data-template ')
105
+
106
+ # Replace simple <template> without attributes
107
+ processed_template = processed_template.gsub(/<template>/, '<div data-template>')
108
+
109
+ # Replace closing tag
110
+ processed_template = processed_template.gsub(/<\/template>/, '</div>')
111
+
112
+ processed_template
113
+ end
114
+
115
+ # Convert self-closing custom element tags to regular tags
116
+ # Custom elements are identified by having hyphens in their name
117
+ # Standard void elements (img, input, etc.) are not converted
118
+ # @param template [String]
119
+ # @return [String]
120
+ def preprocess_self_closing_tags(template)
121
+ # Pattern matches: <tag-name attributes />
122
+ # Where tag-name contains at least one hyphen (custom element convention)
123
+ # Use a more robust pattern that handles nested brackets and quotes
124
+ template.gsub(/<([a-z]+(?:-[a-z]+)+)((?:[^>]|"[^"]*"|'[^']*')*?)\/>/i) do
125
+ tag_name = ::Regexp.last_match(1)
126
+ attributes = ::Regexp.last_match(2)
127
+
128
+ # Convert to regular open/close tags
129
+ "<#{tag_name}#{attributes}></#{tag_name}>"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template/build_conditional_group"
4
+ require_relative "template/build_for_group"
5
+ require_relative "template/build_vdom"
6
+ require_relative "template/parser"
7
+
8
+ module RubyWasmUi
9
+ module Template
10
+ end
11
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmUi
4
+ module Utils
5
+ module Arrays
6
+ # @param arr [Array]
7
+ # @return [Array]
8
+ def self.without_nulls(arr)
9
+ arr.reject { |item| item.nil? }
10
+ end
11
+
12
+ # @param old_array [Array]
13
+ # @param new_array [Array]
14
+ # @return [Hash] Hash containing added and removed items
15
+ def self.diff(old_array, new_array)
16
+ {
17
+ added: (new_array - old_array).uniq,
18
+ removed: (old_array - new_array).uniq
19
+ }
20
+ end
21
+
22
+ class ArrayWithOriginalIndices
23
+ attr_reader :array, :original_indices, :equal_proc
24
+
25
+ ARRAY_DIFF_OP = {
26
+ ADD: "add",
27
+ REMOVE: "remove",
28
+ MOVE: "move",
29
+ NOOP: "noop",
30
+ }.freeze
31
+
32
+ private_constant :ARRAY_DIFF_OP
33
+
34
+ def initialize(array, equal_proc)
35
+ @array = array.dup
36
+ @original_indices = array.each_index.to_a
37
+ @equal_proc = equal_proc
38
+ end
39
+
40
+ def is_removal?(index, new_array)
41
+ return false if index >= length
42
+
43
+ item = @array[index]
44
+ index_in_new_array = new_array.find_index { |new_item| @equal_proc.call(new_item, item) }
45
+
46
+ index_in_new_array.nil?
47
+ end
48
+
49
+ def is_noop?(index, new_array)
50
+ return false if index >= length
51
+
52
+ item = @array[index]
53
+ new_item = new_array[index]
54
+
55
+ @equal_proc.call(item, new_item)
56
+ end
57
+
58
+ def is_addition?(item, from_index)
59
+ return find_index_from(item, from_index).nil?
60
+ end
61
+
62
+ def remove_item(index)
63
+ operation = {
64
+ op: ARRAY_DIFF_OP[:REMOVE],
65
+ index:,
66
+ item: @array[index]
67
+ }
68
+
69
+ @array.delete_at(index)
70
+ @original_indices.delete_at(index)
71
+
72
+ operation
73
+ end
74
+
75
+ def noop_item(index)
76
+ {
77
+ op: ARRAY_DIFF_OP[:NOOP],
78
+ original_index: original_index_at(index),
79
+ index:,
80
+ item: @array[index]
81
+ }
82
+ end
83
+
84
+ def add_item(item, index)
85
+ operation = {
86
+ op: ARRAY_DIFF_OP[:ADD],
87
+ index:,
88
+ item:
89
+ }
90
+
91
+ @array.insert(index, item)
92
+ @original_indices.insert(index, -1)
93
+
94
+ operation
95
+ end
96
+
97
+ def move_item(item, to_index)
98
+ from_index = find_index_from(item, to_index)
99
+
100
+ operation = {
101
+ op: ARRAY_DIFF_OP[:MOVE],
102
+ original_index: original_index_at(from_index),
103
+ from: from_index,
104
+ index: to_index,
105
+ item: @array[from_index]
106
+ }
107
+
108
+ temp_deleted_item = @array.delete_at(from_index)
109
+ @array.insert(to_index, temp_deleted_item)
110
+
111
+ temp_deleted_original_index = @original_indices.delete_at(from_index)
112
+ @original_indices.insert(to_index, temp_deleted_original_index)
113
+
114
+ operation
115
+ end
116
+
117
+ def remove_item_after(index)
118
+ operations = []
119
+
120
+ while index < length
121
+ operations << remove_item(index)
122
+ end
123
+
124
+ operations
125
+ end
126
+
127
+ private
128
+
129
+ def length
130
+ @array.length
131
+ end
132
+
133
+ def original_index_at(index)
134
+ @original_indices[index]
135
+ end
136
+
137
+ def find_index_from(item, from_index)
138
+ (from_index...length).each do |index|
139
+ return index if @equal_proc.call(@array[index], item)
140
+ end
141
+
142
+ nil
143
+ end
144
+ end
145
+
146
+ # @param old_array [Array]
147
+ # @param new_array [Array]
148
+ # @param equal_proc [Proc]
149
+ # @return [Array] sequence of operations to transform old_array into new_array
150
+ def self.diff_sequence(old_array, new_array, equal_proc = ->(a, b) { a == b })
151
+ sequence = []
152
+ array = ArrayWithOriginalIndices.new(old_array, equal_proc)
153
+
154
+ index = 0
155
+ while index < new_array.length
156
+ if array.is_removal?(index, new_array)
157
+ sequence << array.remove_item(index)
158
+ next
159
+ end
160
+
161
+ if array.is_noop?(index, new_array)
162
+ sequence << array.noop_item(index)
163
+ index += 1
164
+ next
165
+ end
166
+
167
+ item = new_array[index]
168
+
169
+ if array.is_addition?(item, index)
170
+ sequence << array.add_item(item, index)
171
+ index += 1
172
+ next
173
+ end
174
+
175
+ sequence << array.move_item(item, index)
176
+ index += 1
177
+ end
178
+
179
+ sequence.concat(array.remove_item_after(new_array.length))
180
+
181
+ sequence
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmUi
4
+ module Utils
5
+ module Objects
6
+ module_function
7
+
8
+ # @param old_obj [Hash]
9
+ # @param new_obj [Hash]
10
+ # @return [Hash]
11
+ def diff(old_obj, new_obj)
12
+ old_keys = old_obj.keys
13
+ new_keys = new_obj.keys
14
+
15
+ {
16
+ added: new_keys.select { |key| !old_obj.key?(key) },
17
+ removed: old_keys.select { |key| !new_obj.key?(key) },
18
+ updated: new_keys.select { |key| old_obj.key?(key) && old_obj[key] != new_obj[key] }
19
+ }
20
+ end
21
+
22
+ # Check if an object has its own property (not inherited)
23
+ # @param obj [Hash, Object] The object to check
24
+ # @param prop [Symbol, String] The property name to check
25
+ # @return [Boolean] true if the object has the property
26
+ def has_own_property(obj, prop)
27
+ case obj
28
+ when Hash
29
+ obj.key?(prop)
30
+ else
31
+ # For other objects, check if it's an instance variable
32
+ obj.instance_variable_defined?("@#{prop}")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ module RubyWasmUi
2
+ module Utils
3
+ module Props
4
+ module_function
5
+
6
+ # Extract props and events from vdom
7
+ # Equivalent to JavaScript:
8
+ # const { on: events = {}, ...props } = vdom.props;
9
+ # delete props.key;
10
+ # @param vdom [RubyWasmUi::Vdom]
11
+ # @return [Hash]
12
+ def extract_props_and_events(vdom)
13
+ return { props: {}, events: {} } unless vdom&.props
14
+
15
+ all_props = vdom.props || {}
16
+ events = all_props[:on] || all_props["on"] || {}
17
+
18
+ # Create props hash excluding the 'on' key and 'key'
19
+ props = all_props.reject { |key, _| key == :on || key == "on" || key == :key || key == "key" }
20
+
21
+ { props:, events: }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ module RubyWasmUi
2
+ module Utils
3
+ module Strings
4
+ module_function
5
+
6
+ # @param str [String]
7
+ # @return [Boolean]
8
+ def is_not_empty_string(str)
9
+ str != ""
10
+ end
11
+
12
+ # @param str [String]
13
+ # @return [Boolean]
14
+ def is_not_blank_or_empty_string(str)
15
+ is_not_empty_string(str.strip)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "utils/arrays"
4
+ require_relative "utils/objects"
5
+ require_relative "utils/props"
6
+ require_relative "utils/strings"
7
+
8
+ module RubyWasmUi
9
+ module Utils
10
+ end
11
+ end