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