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,69 @@
1
+ require "js"
2
+
3
+ random_cocktail = Ruwi.define_component(
4
+ state: ->() {
5
+ {
6
+ is_loading: false,
7
+ cocktail: nil
8
+ }
9
+ },
10
+
11
+ template: ->() {
12
+ is_loading = state[:is_loading] # Used in template
13
+ cocktail = state[:cocktail] # Used in template
14
+
15
+ template = <<~HTML
16
+ <p r-if="{is_loading}">
17
+ Loading...
18
+ </p>
19
+ <button-component r-elsif="{cocktail.nil?}" on="{click_button: ->() { fetch_cocktail }}" label="Get a cocktail"/>
20
+ <template r-else>
21
+ <h1>{cocktail['strDrink']}</h1>
22
+ <p>{cocktail['strInstructions']}</p>
23
+ <img src="{cocktail['strDrinkThumb']}" alt="{cocktail['strDrink']}" style="width: 300px; height: 300px" />
24
+ <button-component on="{click_button: ->() { fetch_cocktail }}" label="Get another cocktail"/>
25
+ </template>
26
+ HTML
27
+
28
+ Ruwi::Template::Parser.parse_and_eval(template, binding)
29
+ },
30
+
31
+ methods: {
32
+ fetch_cocktail: ->{
33
+ # Set loading state
34
+ update_state(is_loading: true, cocktail: nil)
35
+
36
+ Fiber.new do
37
+ response = JS.global.fetch("https://www.thecocktaildb.com/api/json/v1/1/random.php").await
38
+ response.call(:json).then(->(data) {
39
+ update_state(is_loading: false, cocktail: data[:drinks][0])
40
+ }).catch(->(error) {
41
+ update_state(is_loading: false, cocktail: nil)
42
+ })
43
+ end.transfer
44
+ }
45
+ },
46
+
47
+ on_mounted: ->() {
48
+ fetch_cocktail
49
+ }
50
+ )
51
+
52
+ ButtonComponent = Ruwi.define_component(
53
+ state: ->(props) {
54
+ { label: props[:label] }
55
+ },
56
+
57
+ template: ->() {
58
+ Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
59
+ <button on="{click: ->() { emit('click_button') }}" style="display: block; margin: 1em auto">
60
+ {state[:label]}
61
+ </button>
62
+ HTML
63
+ }
64
+ )
65
+
66
+ # Create and mount the app
67
+ app = Ruwi::App.create(random_cocktail)
68
+ app_element = JS.global[:document].getElementById("app")
69
+ app.mount(app_element)
@@ -0,0 +1,101 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test.describe("Random Cocktail Example", () => {
4
+ test("should display loading state and fetch initial cocktail data", async ({
5
+ page,
6
+ }) => {
7
+ await page.goto("/examples/npm-packages/runtime/random_cocktail/index.html?env=DEV");
8
+ await page.waitForTimeout(1000); // Shorter wait to catch loading state
9
+
10
+ // Check the page title
11
+ await expect(page).toHaveTitle("Random Cocktail");
12
+
13
+ // Should initially show loading state or quickly transition to cocktail data
14
+ // const hasLoadingOrCocktail = await Promise.race([
15
+ // page
16
+ // .getByText("Loading...")
17
+ // .isVisible()
18
+ // .catch(() => false),
19
+ // page
20
+ // .locator("#app h1")
21
+ // .isVisible()
22
+ // .catch(() => false),
23
+ // ]);
24
+ // expect(hasLoadingOrCocktail).toBe(true);
25
+
26
+ // Wait for the cocktail to load (API call)
27
+ await page.waitForTimeout(5000);
28
+
29
+ // After loading, should show cocktail information
30
+ await expect(page.locator("#app h1")).toBeVisible();
31
+ await expect(page.locator("#app p").last()).toBeVisible();
32
+ await expect(page.locator("img")).toBeVisible();
33
+ await expect(page.locator("button")).toHaveText("Get another cocktail");
34
+
35
+ // Verify cocktail data is displayed correctly
36
+ const cocktailName = await page.locator("#app h1").textContent();
37
+ expect(cocktailName).toBeTruthy();
38
+ expect(cocktailName.length).toBeGreaterThan(0);
39
+
40
+ const instructions = await page.locator("#app p").last().textContent();
41
+ expect(instructions).toBeTruthy();
42
+ expect(instructions.length).toBeGreaterThan(0);
43
+
44
+ // Verify image attributes and styling
45
+ const img = page.locator("img");
46
+ await expect(img).toHaveAttribute("alt");
47
+ await expect(img).toHaveAttribute("src");
48
+ await expect(img).toHaveCSS("width", "300px");
49
+ await expect(img).toHaveCSS("height", "300px");
50
+
51
+ const imgSrc = await img.getAttribute("src");
52
+ const imgAlt = await img.getAttribute("alt");
53
+ expect(imgSrc).toBeTruthy();
54
+ expect(imgAlt).toBeTruthy();
55
+ expect(imgSrc).toMatch(/^https?:\/\//); // Should be a valid URL
56
+
57
+ // Verify button styling and behavior
58
+ const button = page.locator("button");
59
+ await expect(button).toBeVisible();
60
+ await expect(button).toBeEnabled();
61
+ await expect(button).toHaveCSS("display", "block");
62
+ });
63
+
64
+ test("should fetch new cocktail when button is clicked", async ({ page }) => {
65
+ await page.goto("/examples/npm-packages/runtime/random_cocktail/index.html?env=DEV");
66
+ await page.waitForTimeout(3000);
67
+
68
+ // Wait for initial cocktail to load
69
+ await page.waitForTimeout(5000);
70
+
71
+ // Get the initial cocktail name
72
+ const initialCocktailName = await page.locator("#app h1").textContent();
73
+
74
+ // Click "Get another cocktail" button
75
+ const button = page.locator("button");
76
+ await button.click();
77
+
78
+ // Should show loading state
79
+ await expect(page.getByText("Loading...")).toBeVisible();
80
+
81
+ // Wait for new cocktail to load
82
+ await page.waitForTimeout(5000);
83
+
84
+ // Should display new cocktail data with correct structure
85
+ await expect(page.locator("#app h1")).toBeVisible();
86
+ await expect(page.locator("#app p").last()).toBeVisible();
87
+ await expect(page.locator("img")).toBeVisible();
88
+ await expect(button).toHaveText("Get another cocktail");
89
+
90
+ // Verify we have valid cocktail data
91
+ const newCocktailName = await page.locator("#app h1").textContent();
92
+ expect(newCocktailName).toBeTruthy();
93
+ expect(newCocktailName.length).toBeGreaterThan(0);
94
+
95
+ // Verify the component maintains proper state
96
+ const hasLoading = (await page.getByText("Loading...").count()) > 0;
97
+ const hasCocktail = (await page.locator("#app h1").count()) > 0;
98
+ const hasButton = (await button.count()) > 0;
99
+ expect(hasLoading || hasCocktail || hasButton).toBe(true);
100
+ });
101
+ });
@@ -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/ruwi.js"
15
+ : "https://unpkg.com/ruwi@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 = Ruwi.define_component(
5
+ template: ->() {
6
+ Ruwi::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 = Ruwi.define_component(
19
+ state: ->() {
20
+ { search_term: '' }
21
+ },
22
+ template: ->() {
23
+ Ruwi::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 = Ruwi::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/ruwi.js"
15
+ : "https://unpkg.com/ruwi@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 = Ruwi.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
+ Ruwi::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 = Ruwi.define_component(
74
+ # Initialize component state
75
+ state: ->(props) {
76
+ { text: "" }
77
+ },
78
+
79
+ # Render the new TODO input form
80
+ template: ->() {
81
+ Ruwi::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 = Ruwi.define_component(
116
+ # Render the TODO list
117
+ template: ->() {
118
+ todos = props[:todos]
119
+
120
+ Ruwi::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 = Ruwi.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
+ Ruwi::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 = Ruwi.define_component(
199
+ # Render TODO item in edit mode
200
+ template: ->() {
201
+ Ruwi::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 = Ruwi.define_component(
221
+ # Render TODO item in view mode
222
+ template: ->() {
223
+ Ruwi::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 = Ruwi::App.create(AppComponent)
238
+ app_element = JS.global[:document].getElementById("app")
239
+ app.mount(app_element)