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,27 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
8
+ <script>
9
+ const urlParams = new URLSearchParams(window.location.search);
10
+ const isDev = urlParams.get("env") === "DEV";
11
+ const script = document.createElement("script");
12
+ script.defer = true;
13
+ script.src = isDev
14
+ ? "../../../../packages/npm-packages/runtime/dist/ruby-wasm-ui.js"
15
+ : "https://unpkg.com/ruby-wasm-ui@latest";
16
+ document.head.appendChild(script);
17
+ </script>
18
+ <script defer type="text/ruby" src="index.rb"></script>
19
+
20
+ <title>Search Field</title>
21
+ </head>
22
+
23
+ <body>
24
+ <h1>Search Field</h1>
25
+ <div id="app"></div>
26
+ </body>
27
+ </html>
@@ -0,0 +1,39 @@
1
+ require "js"
2
+
3
+ # search-field component definition
4
+ SearchField = RubyWasmUi.define_component(
5
+ template: ->() {
6
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
7
+ <input
8
+ type="text"
9
+ placeholder="Search..."
10
+ value="{props[:value]}"
11
+ on="{ input: ->(event) { emit('search', event[:target][:value].to_s) } }"
12
+ />
13
+ HTML
14
+ }
15
+ )
16
+
17
+ # search-demo component to show the search functionality
18
+ SearchDemo = RubyWasmUi.define_component(
19
+ state: ->() {
20
+ { search_term: '' }
21
+ },
22
+ template: ->() {
23
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
24
+ <template>
25
+ <h2>Search Demo</h2>
26
+ <search-field
27
+ value="{state[:search_term]}"
28
+ on="{ search: ->(search_term) { update_state({ search_term: search_term }) } }"
29
+ />
30
+ <p>Current search term: {state[:search_term]}</p>
31
+ </template>
32
+ HTML
33
+ }
34
+ )
35
+
36
+ # Create and mount the app
37
+ app = RubyWasmUi::App.create(SearchDemo)
38
+ app_element = JS.global[:document].getElementById("app")
39
+ app.mount(app_element)
@@ -0,0 +1,59 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test.describe("Search Field Example", () => {
4
+ test("should display search demo and handle various input scenarios", async ({
5
+ page,
6
+ }) => {
7
+ await page.goto("/examples/npm-packages/runtime/search_field/index.html?env=DEV");
8
+ await page.waitForTimeout(3000);
9
+
10
+ // Check the page title
11
+ await expect(page).toHaveTitle("Search Field");
12
+
13
+ // Check search demo heading
14
+ await expect(page.locator("h2")).toHaveText("Search Demo");
15
+
16
+ // Check search input field is present
17
+ const searchInput = page.locator('input[type="text"]');
18
+ const searchTermDisplay = page.locator("p");
19
+ await expect(searchInput).toBeVisible();
20
+ await expect(searchInput).toHaveAttribute("placeholder", "Search...");
21
+
22
+ // Check initial search term display
23
+ await expect(searchTermDisplay).toHaveText("Current search term: ");
24
+
25
+ // Test basic search functionality
26
+ await searchInput.fill("test");
27
+ await page.waitForTimeout(100);
28
+ await expect(searchTermDisplay).toHaveText("Current search term: test");
29
+ await expect(searchInput).toHaveValue("test");
30
+
31
+ // Test multiple search term updates
32
+ const searchTerms = ["hello", "world", "ruby", "wasm", ""];
33
+ for (const term of searchTerms) {
34
+ await searchInput.fill(term);
35
+ await page.waitForTimeout(100);
36
+ await expect(searchTermDisplay).toHaveText(
37
+ `Current search term: ${term}`
38
+ );
39
+ await expect(searchInput).toHaveValue(term);
40
+ }
41
+
42
+ // Test special characters in search input
43
+ const specialSearchTerms = [
44
+ "test@example.com",
45
+ "123-456-789",
46
+ "hello world!",
47
+ "search with spaces",
48
+ "special!@#$%^&*()",
49
+ ];
50
+ for (const term of specialSearchTerms) {
51
+ await searchInput.fill(term);
52
+ await page.waitForTimeout(100);
53
+ await expect(searchTermDisplay).toHaveText(
54
+ `Current search term: ${term}`
55
+ );
56
+ await expect(searchInput).toHaveValue(term);
57
+ }
58
+ });
59
+ });
@@ -0,0 +1,28 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
8
+ <script>
9
+ const urlParams = new URLSearchParams(window.location.search);
10
+ const isDev = urlParams.get("env") === "DEV";
11
+ const script = document.createElement("script");
12
+ script.defer = true;
13
+ script.src = isDev
14
+ ? "../../../../packages/npm-packages/runtime/dist/ruby-wasm-ui.js"
15
+ : "https://unpkg.com/ruby-wasm-ui@latest";
16
+ document.head.appendChild(script);
17
+ </script>
18
+ <script defer type="text/ruby" src="todos_repository.rb"></script>
19
+ <script defer type="text/ruby" src="index.rb"></script>
20
+
21
+ <title>Todos</title>
22
+ </head>
23
+
24
+ <body>
25
+ <h1>Todos</h1>
26
+ <div id="app"></div>
27
+ </body>
28
+ </html>
@@ -0,0 +1,239 @@
1
+ require "js"
2
+
3
+ # Main App component - coordinates all other components
4
+ AppComponent = RubyWasmUi.define_component(
5
+ # Initialize application state
6
+ state: ->(props) {
7
+ {
8
+ todos: [
9
+ { id: rand(10000), text: "Walk the dog" },
10
+ { id: rand(10000), text: "Water the plants" },
11
+ { id: rand(10000), text: "Sand the chairs" }
12
+ ]
13
+ }
14
+ },
15
+
16
+ on_mounted: -> {
17
+ update_state(todos: TodosRepository.read_todos)
18
+ },
19
+
20
+ # Render the complete application
21
+ template: ->() {
22
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
23
+ <template>
24
+ <h1>My TODOs</h1>
25
+ <CreateTodoComponent
26
+ on="{ add: ->(text) { add_todo(text) } }"
27
+ />
28
+ <TodoListComponent
29
+ todos="{state[:todos]}"
30
+ on="{
31
+ remove: ->(id) { remove_todo(id) },
32
+ edit: ->(payload) { edit_todo(payload) }
33
+ }"
34
+ />
35
+ </template>
36
+ HTML
37
+ },
38
+
39
+ # Component methods
40
+ methods: {
41
+ # Add a new TODO to the list
42
+ # @param text [String] The TODO text
43
+ add_todo: ->(text) {
44
+ todo = { id: rand(10000), text: text }
45
+ new_todos = state[:todos] + [todo]
46
+ update_state(todos: new_todos)
47
+ TodosRepository.write_todos(new_todos)
48
+ },
49
+
50
+ # Remove a TODO from the list
51
+ # @param id [Integer] Id of TODO to remove
52
+ remove_todo: ->(id) {
53
+ new_todos = state[:todos].dup
54
+ new_todos.delete_at(new_todos.index { |todo| todo[:id] == id })
55
+ update_state(todos: new_todos)
56
+ TodosRepository.write_todos(new_todos)
57
+ },
58
+
59
+ # Edit an existing TODO
60
+ # @param payload [Hash] Contains edited text and index
61
+ edit_todo: ->(payload) {
62
+ edited = payload[:edited]
63
+ id = payload[:id]
64
+ new_todos = state[:todos].dup
65
+ new_todos[new_todos.index { |todo| todo[:id] == id }] = new_todos[new_todos.index { |todo| todo[:id] == id }].merge(text: edited)
66
+ update_state(todos: new_todos)
67
+ TodosRepository.write_todos(new_todos)
68
+ }
69
+ }
70
+ )
71
+
72
+ # CreateTodo component - handles new TODO input
73
+ CreateTodoComponent = RubyWasmUi.define_component(
74
+ # Initialize component state
75
+ state: ->(props) {
76
+ { text: "" }
77
+ },
78
+
79
+ # Render the new TODO input form
80
+ template: ->() {
81
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
82
+ <div>
83
+ <label for="todo-input" type="text">New TODO</label>
84
+ <input
85
+ type="text"
86
+ id="todo-input"
87
+ value="{state[:text]}"
88
+ on="{
89
+ input: ->(e) { update_state(text: e[:target][:value]) },
90
+ keydown: ->(e) {
91
+ if e[:key] == 'Enter' && state[:text].to_s.length >= 3
92
+ add_todo
93
+ end
94
+ }
95
+ }"
96
+ />
97
+ <button disabled="{state[:text].to_s.length < 3}" on="{ click: ->() { add_todo } }">
98
+ Add
99
+ </button>
100
+ </div>
101
+ HTML
102
+ },
103
+
104
+ # Component methods
105
+ methods: {
106
+ # Add a new TODO and emit event to parent
107
+ add_todo: ->() {
108
+ emit("add", state[:text])
109
+ update_state(text: "")
110
+ }
111
+ }
112
+ )
113
+
114
+ # TodoList component - manages the list of TODO items
115
+ TodoListComponent = RubyWasmUi.define_component(
116
+ # Render the TODO list
117
+ template: ->() {
118
+ todos = props[:todos]
119
+
120
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
121
+ <ul>
122
+ <TodoItemComponent
123
+ r-for="{todo in todos}"
124
+ key="{todo[:id]}"
125
+ todo="{todo[:text]}"
126
+ id="{todo[:id]}"
127
+ on="{
128
+ remove: ->(id) { emit('remove', id) },
129
+ edit: ->(payload) { emit('edit', payload) }
130
+ }"
131
+ />
132
+ </ul>
133
+ HTML
134
+ },
135
+ )
136
+
137
+ # TodoItem component - handles individual TODO items
138
+ TodoItemComponent = RubyWasmUi.define_component(
139
+ # Initialize component state with editing capabilities
140
+ state: ->(props) {
141
+ {
142
+ original: props[:todo],
143
+ edited: props[:todo],
144
+ is_editing: false
145
+ }
146
+ },
147
+
148
+ # Render TODO item using r-if and r-else for conditional rendering
149
+ template: ->() {
150
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
151
+ <template>
152
+ <TodoItemEditComponent
153
+ r-if="{state[:is_editing]}"
154
+ edited="{state[:edited]}"
155
+ on="{
156
+ input: ->(value) { input_value(value) },
157
+ save: ->() { save_edition },
158
+ cancel: ->() { cancel_edition }
159
+ }"
160
+ />
161
+ <TodoItemViewComponent
162
+ r-else
163
+ original="{state[:original]}"
164
+ id="{props[:id]}"
165
+ on="{
166
+ editing: ->() { editing },
167
+ remove: ->(id) { emit('remove', id) }
168
+ }"
169
+ />
170
+ </template>
171
+ HTML
172
+ },
173
+
174
+ # Component methods
175
+ methods: {
176
+ input_value: ->(value) {
177
+ update_state(edited: value)
178
+ },
179
+
180
+ editing: ->() {
181
+ update_state(is_editing: true)
182
+ },
183
+
184
+ # Save the edited TODO
185
+ save_edition: ->() {
186
+ update_state(is_editing: false, original: state[:edited])
187
+ emit("edit", { edited: state[:edited], id: props[:id] })
188
+ },
189
+
190
+ # Cancel editing and revert changes
191
+ cancel_edition: ->() {
192
+ update_state(edited: state[:original], is_editing: false)
193
+ }
194
+ }
195
+ )
196
+
197
+ # TodoItemEdit component - handles TODO editing mode
198
+ TodoItemEditComponent = RubyWasmUi.define_component(
199
+ # Render TODO item in edit mode
200
+ template: ->() {
201
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
202
+ <li>
203
+ <input
204
+ value="{props[:edited]}"
205
+ type="text"
206
+ on="{ input: ->(e) { emit('input', e[:target][:value]) } }"
207
+ />
208
+ <button on="{ click: ->() { emit('save') } }">
209
+ Save
210
+ </button>
211
+ <button on="{ click: ->() { emit('cancel') } }">
212
+ Cancel
213
+ </button>
214
+ </li>
215
+ HTML
216
+ },
217
+ )
218
+
219
+ # TodoItemView component - handles TODO display mode
220
+ TodoItemViewComponent = RubyWasmUi.define_component(
221
+ # Render TODO item in view mode
222
+ template: ->() {
223
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
224
+ <li>
225
+ <span on="{ dblclick: ->() { emit('editing') } }">
226
+ {props[:original]}
227
+ </span>
228
+ <button on="{ click: ->() { emit('remove', props[:id]) } }">
229
+ Done
230
+ </button>
231
+ </li>
232
+ HTML
233
+ },
234
+ )
235
+
236
+ # Create and mount the application
237
+ app = RubyWasmUi::App.create(AppComponent)
238
+ app_element = JS.global[:document].getElementById("app")
239
+ app.mount(app_element)
@@ -0,0 +1,161 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test.describe("Todos Example", () => {
4
+ // Clear localStorage before each test to ensure clean state
5
+ test.beforeEach(async ({ page }) => {
6
+ await page.goto("/examples/npm-packages/runtime/todos/index.html?env=DEV");
7
+ await page.evaluate(() => localStorage.clear());
8
+ await page.reload();
9
+ await page.waitForTimeout(3000);
10
+ });
11
+
12
+ test("should display todos app and handle basic add/remove operations", async ({
13
+ page,
14
+ }) => {
15
+ // Check the page title and basic layout
16
+ await expect(page).toHaveTitle("Todos");
17
+ await expect(page.locator("#app h1")).toHaveText("My TODOs");
18
+
19
+ // Check new todo form elements
20
+ const todoInput = page.locator("#todo-input");
21
+ const addButton = page.locator("button").first();
22
+ await expect(page.locator('label[for="todo-input"]')).toHaveText(
23
+ "New TODO"
24
+ );
25
+ await expect(todoInput).toBeVisible();
26
+ await expect(addButton).toHaveText("Add");
27
+
28
+ // Check that initial todos are displayed (from component state)
29
+ const todoItems = page.locator("li");
30
+ await expect(todoItems).toHaveCount(3);
31
+ await expect(page.locator("li span")).toHaveCount(3);
32
+
33
+ // Test add button validation - initially disabled (empty input)
34
+ await expect(addButton).toBeDisabled();
35
+
36
+ // Type less than 3 characters - should keep button disabled
37
+ await todoInput.fill("ab");
38
+ await page.waitForTimeout(100);
39
+ await expect(addButton).toBeDisabled();
40
+
41
+ // Type 3 or more characters - should enable the button
42
+ await todoInput.fill("New test todo");
43
+ await page.waitForTimeout(100);
44
+ await expect(addButton).toBeEnabled();
45
+
46
+ // Add the todo via button click
47
+ const initialTodoCount = await page.locator("li").count();
48
+ await addButton.click();
49
+ await page.waitForTimeout(100);
50
+
51
+ await expect(page.locator("li")).toHaveCount(initialTodoCount + 1);
52
+ await expect(todoInput).toHaveValue("");
53
+ await expect(addButton).toBeDisabled();
54
+ await expect(page.locator("li span").last()).toHaveText("New test todo");
55
+
56
+ // Test adding todo with Enter key
57
+ await todoInput.fill("Todo added with Enter");
58
+ await page.waitForTimeout(100);
59
+ const currentTodoCount = await page.locator("li").count();
60
+
61
+ await todoInput.press("Enter");
62
+ await page.waitForTimeout(100);
63
+
64
+ await expect(page.locator("li")).toHaveCount(currentTodoCount + 1);
65
+ await expect(todoInput).toHaveValue("");
66
+ await expect(page.locator("li span").last()).toHaveText(
67
+ "Todo added with Enter"
68
+ );
69
+
70
+ // Test that Enter doesn't work with less than 3 characters
71
+ await todoInput.fill("ab");
72
+ await page.waitForTimeout(100);
73
+ const countBeforeInvalidEnter = await page.locator("li").count();
74
+
75
+ await todoInput.press("Enter");
76
+ await page.waitForTimeout(100);
77
+
78
+ await expect(page.locator("li")).toHaveCount(countBeforeInvalidEnter);
79
+ await expect(todoInput).toHaveValue("ab");
80
+
81
+ // Test remove todo functionality
82
+ const countBeforeRemove = await page.locator("li").count();
83
+ await page.locator("li button").first().click();
84
+ await page.waitForTimeout(100);
85
+
86
+ await expect(page.locator("li")).toHaveCount(countBeforeRemove - 1);
87
+ });
88
+
89
+ test("should handle todo editing operations", async ({ page }) => {
90
+ // Get the original text of the first todo
91
+ const originalText = await page.locator("li span").first().textContent();
92
+
93
+ // Test entering edit mode by double-clicking
94
+ await page.locator("li span").first().dblclick();
95
+ await page.waitForTimeout(100);
96
+
97
+ // Should show edit input and buttons
98
+ await expect(page.locator('li input[type="text"]').first()).toBeVisible();
99
+ await expect(page.locator("li button").first()).toHaveText("Save");
100
+ await expect(page.locator("li button").nth(1)).toHaveText("Cancel");
101
+
102
+ // Test saving edited todo
103
+ const editInput = page.locator('li input[type="text"]').first();
104
+ await editInput.fill("Edited todo text");
105
+ await page.waitForTimeout(100);
106
+
107
+ await page.locator("li button").first().click(); // Save button
108
+ await page.waitForTimeout(100);
109
+
110
+ // Should exit edit mode and show updated text
111
+ await expect(page.locator("li span").first()).toHaveText(
112
+ "Edited todo text"
113
+ );
114
+ await expect(
115
+ page.locator('li input[type="text"]').first()
116
+ ).not.toBeVisible();
117
+
118
+ // Test canceling edit operation
119
+ await page.locator("li span").first().dblclick();
120
+ await page.waitForTimeout(100);
121
+
122
+ const editInput2 = page.locator('li input[type="text"]').first();
123
+ await editInput2.fill("This should be cancelled");
124
+ await page.waitForTimeout(100);
125
+
126
+ await page.locator("li button").nth(1).click(); // Cancel button
127
+ await page.waitForTimeout(100);
128
+
129
+ // Should exit edit mode and show previous text (not cancelled text)
130
+ await expect(page.locator("li span").first()).toHaveText(
131
+ "Edited todo text"
132
+ );
133
+ await expect(
134
+ page.locator('li input[type="text"]').first()
135
+ ).not.toBeVisible();
136
+ });
137
+
138
+ test("should persist todos in localStorage", async ({ page }) => {
139
+ // Initial count should be 3 (from component state)
140
+ await expect(page.locator("li")).toHaveCount(3);
141
+
142
+ // Add a new todo
143
+ const todoInput = page.locator("#todo-input");
144
+ await todoInput.fill("Persistent todo");
145
+ await page.locator("button").first().click();
146
+ await page.waitForTimeout(100);
147
+
148
+ // Should now have 4 todos
149
+ await expect(page.locator("li")).toHaveCount(4);
150
+
151
+ // Reload the page
152
+ await page.reload();
153
+ await page.waitForTimeout(3000);
154
+
155
+ // Should still have 4 todos (loaded from localStorage)
156
+ await expect(page.locator("li")).toHaveCount(4);
157
+
158
+ // Should still contain the persistent todo
159
+ await expect(page.locator("li span")).toContainText(["Persistent todo"]);
160
+ });
161
+ });
@@ -0,0 +1,23 @@
1
+ require "json"
2
+
3
+ # Repository class for managing todos in local storage
4
+ class TodosRepository
5
+ class << self
6
+ # Read todos from local storage
7
+ # @return [Array] Array of todo items
8
+ def read_todos
9
+ todos_json = JS.global[:localStorage].getItem('todos') || '[]'
10
+ JSON.parse(todos_json.to_s).map do |todo|
11
+ { id: todo["id"], text: todo["text"] }
12
+ end
13
+ end
14
+
15
+ # Write todos to local storage
16
+ # @param todos [Array] Array of todo items to be stored
17
+ # @return [void]
18
+ def write_todos(todos)
19
+ todos_json = JSON.generate(todos)
20
+ JS.global[:localStorage].setItem('todos', todos_json)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "gem",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "description": "A Ruby gem for the ruby-wasm-ui framework",
6
+ "license": "MIT",
7
+ "author": "t0yohei <k.t0yohei@gmail.com>",
8
+ "type": "module",
9
+ "scripts": {
10
+ "serve": "npx http-server . -o './gem' --cors -P 'http://localhost:8080?' -c-1"
11
+ }
12
+ }
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Counter</title>
6
+ <script type="module">
7
+ import { DefaultRubyVM } from "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser/+esm";
8
+ const response = await fetch("../../src.wasm");
9
+ const module = await WebAssembly.compileStreaming(response);
10
+ const { vm } = await DefaultRubyVM(module);
11
+ vm.evalAsync(`
12
+ require "ruby_wasm_ui"
13
+ require_relative './src/counter/index.rb'
14
+ `);
15
+ </script>
16
+ </head>
17
+ <body>
18
+ <h1>Counter</h1>
19
+
20
+ <div id="app-a"></div>
21
+ <div id="app-b"></div>
22
+ </body>
23
+ </html>
@@ -0,0 +1,60 @@
1
+ # Counter component using the latest component-based API with TemplateParser
2
+ CounterComponent = RubyWasmUi.define_component(
3
+ # Initialize component state
4
+ state: ->(props) {
5
+ { count: props[:count] || 0 }
6
+ },
7
+
8
+ # Render the counter component
9
+ template: ->() {
10
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
11
+ <div>
12
+ <div>{state[:count]}</div>
13
+ <!-- Both ButtonComponent and button-component are valid -->
14
+ <ButtonComponent
15
+ label="Increment"
16
+ on="{ click_button: -> { increment } }">
17
+ </ButtonComponent>
18
+ <button-component
19
+ label="Decrement"
20
+ on="{ click_button: -> { decrement } }"
21
+ />
22
+ </div>
23
+ HTML
24
+ },
25
+
26
+ # Component methods
27
+ methods: {
28
+ # Increment the counter
29
+ increment: ->() {
30
+ update_state(count: state[:count] + 1)
31
+ },
32
+
33
+ # Decrement the counter
34
+ decrement: ->() {
35
+ update_state(count: state[:count] - 1)
36
+ }
37
+ }
38
+ )
39
+
40
+ # Button component - reusable button with click handler
41
+ ButtonComponent = RubyWasmUi.define_component(
42
+ template: ->() {
43
+ RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding)
44
+ <button on="{ click: ->() { emit('click_button') } }">
45
+ {props[:label]}
46
+ </button>
47
+ HTML
48
+ }
49
+ )
50
+
51
+ # app_a to be destroyed
52
+ app_a = RubyWasmUi::App.create(CounterComponent, count: 0)
53
+ app_element_a = JS.global[:document].getElementById("app-a")
54
+ app_a.mount(app_element_a)
55
+ app_a.unmount
56
+
57
+ # app_b to be mounted
58
+ app_b = RubyWasmUi::App.create(CounterComponent, count: 10)
59
+ app_element_b = JS.global[:document].getElementById("app-b")
60
+ app_b.mount(app_element_b)
data/lib/ruby_wasm_ui ADDED
@@ -0,0 +1 @@
1
+ ../packages/npm-packages/runtime/src/ruby_wasm_ui
@@ -0,0 +1 @@
1
+ ../packages/npm-packages/runtime/src/ruby_wasm_ui.rb