ruwi 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) 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 +31 -0
  5. data/.node-version +1 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/Makefile +56 -0
  9. data/README.md +237 -0
  10. data/Rakefile +4 -0
  11. data/docs/conditional-rendering.md +119 -0
  12. data/docs/lifecycle-hooks.md +75 -0
  13. data/docs/list-rendering.md +51 -0
  14. data/examples/.gitignore +4 -0
  15. data/examples/Gemfile +5 -0
  16. data/examples/Gemfile.lock +39 -0
  17. data/examples/Makefile +15 -0
  18. data/examples/npm-packages/runtime/counter/index.html +28 -0
  19. data/examples/npm-packages/runtime/counter/index.rb +62 -0
  20. data/examples/npm-packages/runtime/counter/index.spec.js +42 -0
  21. data/examples/npm-packages/runtime/hello/index.html +28 -0
  22. data/examples/npm-packages/runtime/hello/index.rb +29 -0
  23. data/examples/npm-packages/runtime/hello/index.spec.js +53 -0
  24. data/examples/npm-packages/runtime/input/index.html +28 -0
  25. data/examples/npm-packages/runtime/input/index.rb +46 -0
  26. data/examples/npm-packages/runtime/input/index.spec.js +58 -0
  27. data/examples/npm-packages/runtime/list/index.html +27 -0
  28. data/examples/npm-packages/runtime/list/index.rb +33 -0
  29. data/examples/npm-packages/runtime/list/index.spec.js +46 -0
  30. data/examples/npm-packages/runtime/on_mounted_demo/index.html +40 -0
  31. data/examples/npm-packages/runtime/on_mounted_demo/index.rb +59 -0
  32. data/examples/npm-packages/runtime/on_mounted_demo/index.spec.js +50 -0
  33. data/examples/npm-packages/runtime/r_if_attribute_demo/index.html +34 -0
  34. data/examples/npm-packages/runtime/r_if_attribute_demo/index.rb +113 -0
  35. data/examples/npm-packages/runtime/r_if_attribute_demo/index.spec.js +140 -0
  36. data/examples/npm-packages/runtime/random_cocktail/index.html +27 -0
  37. data/examples/npm-packages/runtime/random_cocktail/index.rb +69 -0
  38. data/examples/npm-packages/runtime/random_cocktail/index.spec.js +101 -0
  39. data/examples/npm-packages/runtime/search_field/index.html +27 -0
  40. data/examples/npm-packages/runtime/search_field/index.rb +39 -0
  41. data/examples/npm-packages/runtime/search_field/index.spec.js +59 -0
  42. data/examples/npm-packages/runtime/todos/index.html +28 -0
  43. data/examples/npm-packages/runtime/todos/index.rb +239 -0
  44. data/examples/npm-packages/runtime/todos/index.spec.js +161 -0
  45. data/examples/npm-packages/runtime/todos/todos_repository.rb +23 -0
  46. data/examples/package.json +12 -0
  47. data/examples/src/counter/index.html +23 -0
  48. data/examples/src/counter/index.rb +60 -0
  49. data/examples/src/index.html +21 -0
  50. data/examples/src/index.rb +26 -0
  51. data/examples/src/todos/index.html +23 -0
  52. data/examples/src/todos/index.rb +237 -0
  53. data/examples/src/todos/todos_repository.rb +23 -0
  54. data/exe/ruwi +6 -0
  55. data/lib/ruwi/cli/command/base.rb +192 -0
  56. data/lib/ruwi/cli/command/dev.rb +207 -0
  57. data/lib/ruwi/cli/command/pack.rb +36 -0
  58. data/lib/ruwi/cli/command/rebuild.rb +38 -0
  59. data/lib/ruwi/cli/command/setup.rb +159 -0
  60. data/lib/ruwi/cli/command.rb +48 -0
  61. data/lib/ruwi/runtime/app.rb +53 -0
  62. data/lib/ruwi/runtime/component.rb +215 -0
  63. data/lib/ruwi/runtime/dispatcher.rb +46 -0
  64. data/lib/ruwi/runtime/dom/attributes.rb +105 -0
  65. data/lib/ruwi/runtime/dom/destroy_dom.rb +63 -0
  66. data/lib/ruwi/runtime/dom/events.rb +40 -0
  67. data/lib/ruwi/runtime/dom/mount_dom.rb +108 -0
  68. data/lib/ruwi/runtime/dom/patch_dom.rb +237 -0
  69. data/lib/ruwi/runtime/dom/scheduler.rb +51 -0
  70. data/lib/ruwi/runtime/dom.rb +13 -0
  71. data/lib/ruwi/runtime/nodes_equal.rb +45 -0
  72. data/lib/ruwi/runtime/template/build_conditional_group.rb +150 -0
  73. data/lib/ruwi/runtime/template/build_for_group.rb +125 -0
  74. data/lib/ruwi/runtime/template/build_vdom.rb +220 -0
  75. data/lib/ruwi/runtime/template/parser.rb +134 -0
  76. data/lib/ruwi/runtime/template.rb +11 -0
  77. data/lib/ruwi/runtime/utils/arrays.rb +185 -0
  78. data/lib/ruwi/runtime/utils/objects.rb +37 -0
  79. data/lib/ruwi/runtime/utils/props.rb +25 -0
  80. data/lib/ruwi/runtime/utils/strings.rb +19 -0
  81. data/lib/ruwi/runtime/utils.rb +11 -0
  82. data/lib/ruwi/runtime/vdom.rb +84 -0
  83. data/lib/ruwi/version.rb +5 -0
  84. data/lib/ruwi.rb +14 -0
  85. data/package-lock.json +73 -0
  86. data/package.json +32 -0
  87. data/packages/npm-packages/runtime/README.md +5 -0
  88. data/packages/npm-packages/runtime/eslint.config.mjs +16 -0
  89. data/packages/npm-packages/runtime/package-lock.json +6668 -0
  90. data/packages/npm-packages/runtime/package.json +38 -0
  91. data/packages/npm-packages/runtime/rollup.config.mjs +147 -0
  92. data/packages/npm-packages/runtime/src/__tests__/sample.test.js +5 -0
  93. data/packages/npm-packages/runtime/src/index.js +37 -0
  94. data/packages/npm-packages/runtime/src/ruwi +1 -0
  95. data/packages/npm-packages/runtime/src/ruwi.rb +1 -0
  96. data/packages/npm-packages/runtime/vitest.config.js +8 -0
  97. data/playwright.config.js +78 -0
  98. data/sig/ruwi.rbs +4 -0
  99. data/spec/ruwi/cli/command/base_spec.rb +503 -0
  100. data/spec/ruwi/cli/command/dev_spec.rb +442 -0
  101. data/spec/ruwi/cli/command/pack_spec.rb +131 -0
  102. data/spec/ruwi/cli/command/rebuild_spec.rb +95 -0
  103. data/spec/ruwi/cli/command/setup_spec.rb +251 -0
  104. data/spec/ruwi/cli/command_spec.rb +118 -0
  105. data/spec/ruwi/runtime/component_spec.rb +416 -0
  106. data/spec/ruwi/runtime/dom/scheduler_spec.rb +98 -0
  107. data/spec/ruwi/runtime/nodes_equal_spec.rb +190 -0
  108. data/spec/ruwi/runtime/template/build_conditional_group_spec.rb +505 -0
  109. data/spec/ruwi/runtime/template/build_for_group_spec.rb +377 -0
  110. data/spec/ruwi/runtime/template/build_vdom_spec.rb +573 -0
  111. data/spec/ruwi/runtime/template/parser_spec.rb +627 -0
  112. data/spec/ruwi/runtime/utils/arrays_spec.rb +228 -0
  113. data/spec/ruwi/runtime/utils/objects_spec.rb +127 -0
  114. data/spec/ruwi/runtime/utils/props_spec.rb +205 -0
  115. data/spec/ruwi/runtime/utils/strings_spec.rb +107 -0
  116. data/spec/spec_helper.rb +16 -0
  117. metadata +229 -0
@@ -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 ruwi 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 "ruwi"
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 = Ruwi.define_component(
3
+ # Initialize component state
4
+ state: ->(props) {
5
+ { count: props[:count] || 0 }
6
+ },
7
+
8
+ # Render the counter component
9
+ template: ->() {
10
+ Ruwi::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 = Ruwi.define_component(
42
+ template: ->() {
43
+ Ruwi::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 = Ruwi::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 = Ruwi::App.create(CounterComponent, count: 10)
59
+ app_element_b = JS.global[:document].getElementById("app-b")
60
+ app_b.mount(app_element_b)
@@ -0,0 +1,21 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>My App</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 "ruwi"
13
+ require_relative './src/index.rb'
14
+ `);
15
+ </script>
16
+ </head>
17
+ <body>
18
+ <h1>My App</h1>
19
+ <div id="app"></div>
20
+ </body>
21
+ </html>
@@ -0,0 +1,26 @@
1
+ # Simple Hello World component
2
+ HelloComponent = Ruwi.define_component(
3
+ state: ->(props) {
4
+ { message: props[:message] || "Hello, Ruby WASM UI!" }
5
+ },
6
+ template: ->() {
7
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
8
+ <div>
9
+ <h2>{state[:message]}</h2>
10
+ <button on="{ click: -> { update_message } }">
11
+ Click me!
12
+ </button>
13
+ </div>
14
+ HTML
15
+ },
16
+ methods: {
17
+ update_message: ->() {
18
+ update_state(message: "You clicked the button!")
19
+ }
20
+ }
21
+ )
22
+
23
+ # Create and mount the app
24
+ app = Ruwi::App.create(HelloComponent, message: "Hello, Ruby WASM UI!")
25
+ app_element = JS.global[:document].getElementById("app")
26
+ app.mount(app_element)
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Todos</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 "ruwi"
13
+ require_relative './src/todos/todos_repository.rb'
14
+ require_relative './src/todos/index.rb'
15
+ `);
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <h1>Todos</h1>
20
+
21
+ <div id="app"></div>
22
+ </body>
23
+ </html>
@@ -0,0 +1,237 @@
1
+ # Main App component - coordinates all other components
2
+ AppComponent = Ruwi.define_component(
3
+ # Initialize application state
4
+ state: ->(props) {
5
+ {
6
+ todos: [
7
+ { id: rand(10000), text: "Walk the dog" },
8
+ { id: rand(10000), text: "Water the plants" },
9
+ { id: rand(10000), text: "Sand the chairs" }
10
+ ]
11
+ }
12
+ },
13
+
14
+ on_mounted: -> {
15
+ update_state(todos: TodosRepository.read_todos)
16
+ },
17
+
18
+ # Render the complete application
19
+ template: ->() {
20
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
21
+ <template>
22
+ <h1>My TODOs</h1>
23
+ <CreateTodoComponent
24
+ on="{ add: ->(text) { add_todo(text) } }"
25
+ />
26
+ <TodoListComponent
27
+ todos="{state[:todos]}"
28
+ on="{
29
+ remove: ->(id) { remove_todo(id) },
30
+ edit: ->(payload) { edit_todo(payload) }
31
+ }"
32
+ />
33
+ </template>
34
+ HTML
35
+ },
36
+
37
+ # Component methods
38
+ methods: {
39
+ # Add a new TODO to the list
40
+ # @param text [String] The TODO text
41
+ add_todo: ->(text) {
42
+ todo = { id: rand(10000), text: text }
43
+ new_todos = state[:todos] + [todo]
44
+ update_state(todos: new_todos)
45
+ TodosRepository.write_todos(new_todos)
46
+ },
47
+
48
+ # Remove a TODO from the list
49
+ # @param id [Integer] Id of TODO to remove
50
+ remove_todo: ->(id) {
51
+ new_todos = state[:todos].dup
52
+ new_todos.delete_at(new_todos.index { |todo| todo[:id] == id })
53
+ update_state(todos: new_todos)
54
+ TodosRepository.write_todos(new_todos)
55
+ },
56
+
57
+ # Edit an existing TODO
58
+ # @param payload [Hash] Contains edited text and index
59
+ edit_todo: ->(payload) {
60
+ edited = payload[:edited]
61
+ id = payload[:id]
62
+ new_todos = state[:todos].dup
63
+ new_todos[new_todos.index { |todo| todo[:id] == id }] = new_todos[new_todos.index { |todo| todo[:id] == id }].merge(text: edited)
64
+ update_state(todos: new_todos)
65
+ TodosRepository.write_todos(new_todos)
66
+ }
67
+ }
68
+ )
69
+
70
+ # CreateTodo component - handles new TODO input
71
+ CreateTodoComponent = Ruwi.define_component(
72
+ # Initialize component state
73
+ state: ->(props) {
74
+ { text: "" }
75
+ },
76
+
77
+ # Render the new TODO input form
78
+ template: ->() {
79
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
80
+ <div>
81
+ <label for="todo-input" type="text">New TODO</label>
82
+ <input
83
+ type="text"
84
+ id="todo-input"
85
+ value="{state[:text]}"
86
+ on="{
87
+ input: ->(e) { update_state(text: e[:target][:value]) },
88
+ keydown: ->(e) {
89
+ if e[:key] == 'Enter' && state[:text].to_s.length >= 3
90
+ add_todo
91
+ end
92
+ }
93
+ }"
94
+ />
95
+ <button disabled="{state[:text].to_s.length < 3}" on="{ click: ->() { add_todo } }">
96
+ Add
97
+ </button>
98
+ </div>
99
+ HTML
100
+ },
101
+
102
+ # Component methods
103
+ methods: {
104
+ # Add a new TODO and emit event to parent
105
+ add_todo: ->() {
106
+ emit("add", state[:text])
107
+ update_state(text: "")
108
+ }
109
+ }
110
+ )
111
+
112
+ # TodoList component - manages the list of TODO items
113
+ TodoListComponent = Ruwi.define_component(
114
+ # Render the TODO list
115
+ template: ->() {
116
+ todos = props[:todos]
117
+
118
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
119
+ <ul>
120
+ <TodoItemComponent
121
+ r-for="{todo in todos}"
122
+ key="{todo[:id]}"
123
+ todo="{todo[:text]}"
124
+ id="{todo[:id]}"
125
+ on="{
126
+ remove: ->(id) { emit('remove', id) },
127
+ edit: ->(payload) { emit('edit', payload) }
128
+ }"
129
+ />
130
+ </ul>
131
+ HTML
132
+ },
133
+ )
134
+
135
+ # TodoItem component - handles individual TODO items
136
+ TodoItemComponent = Ruwi.define_component(
137
+ # Initialize component state with editing capabilities
138
+ state: ->(props) {
139
+ {
140
+ original: props[:todo],
141
+ edited: props[:todo],
142
+ is_editing: false
143
+ }
144
+ },
145
+
146
+ # Render TODO item using r-if and r-else for conditional rendering
147
+ template: ->() {
148
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
149
+ <template>
150
+ <TodoItemEditComponent
151
+ r-if="{state[:is_editing]}"
152
+ edited="{state[:edited]}"
153
+ on="{
154
+ input: ->(value) { input_value(value) },
155
+ save: ->() { save_edition },
156
+ cancel: ->() { cancel_edition }
157
+ }"
158
+ />
159
+ <TodoItemViewComponent
160
+ r-else
161
+ original="{state[:original]}"
162
+ id="{props[:id]}"
163
+ on="{
164
+ editing: ->() { editing },
165
+ remove: ->(id) { emit('remove', id) }
166
+ }"
167
+ />
168
+ </template>
169
+ HTML
170
+ },
171
+
172
+ # Component methods
173
+ methods: {
174
+ input_value: ->(value) {
175
+ update_state(edited: value)
176
+ },
177
+
178
+ editing: ->() {
179
+ update_state(is_editing: true)
180
+ },
181
+
182
+ # Save the edited TODO
183
+ save_edition: ->() {
184
+ update_state(is_editing: false, original: state[:edited])
185
+ emit("edit", { edited: state[:edited], id: props[:id] })
186
+ },
187
+
188
+ # Cancel editing and revert changes
189
+ cancel_edition: ->() {
190
+ update_state(edited: state[:original], is_editing: false)
191
+ }
192
+ }
193
+ )
194
+
195
+ # TodoItemEdit component - handles TODO editing mode
196
+ TodoItemEditComponent = Ruwi.define_component(
197
+ # Render TODO item in edit mode
198
+ template: ->() {
199
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
200
+ <li>
201
+ <input
202
+ value="{props[:edited]}"
203
+ type="text"
204
+ on="{ input: ->(e) { emit('input', e[:target][:value]) } }"
205
+ />
206
+ <button on="{ click: ->() { emit('save') } }">
207
+ Save
208
+ </button>
209
+ <button on="{ click: ->() { emit('cancel') } }">
210
+ Cancel
211
+ </button>
212
+ </li>
213
+ HTML
214
+ },
215
+ )
216
+
217
+ # TodoItemView component - handles TODO display mode
218
+ TodoItemViewComponent = Ruwi.define_component(
219
+ # Render TODO item in view mode
220
+ template: ->() {
221
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
222
+ <li>
223
+ <span on="{ dblclick: ->() { emit('editing') } }">
224
+ {props[:original]}
225
+ </span>
226
+ <button on="{ click: ->() { emit('remove', props[:id]) } }">
227
+ Done
228
+ </button>
229
+ </li>
230
+ HTML
231
+ },
232
+ )
233
+
234
+ # Create and mount the application
235
+ app = Ruwi::App.create(AppComponent)
236
+ app_element = JS.global[:document].getElementById("app")
237
+ app.mount(app_element)
@@ -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
data/exe/ruwi ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ruwi/cli/command"
5
+
6
+ Ruwi::Cli::Command.run(ARGV)