funicular 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
data/docs/advanced-features.md
DELETED
|
@@ -1,632 +0,0 @@
|
|
|
1
|
-
# Advanced Features
|
|
2
|
-
|
|
3
|
-
This guide covers advanced Funicular features including Error Boundaries, CSS Transitions, and JavaScript integration.
|
|
4
|
-
|
|
5
|
-
## Table of Contents
|
|
6
|
-
|
|
7
|
-
- [Error Boundary](#error-boundary)
|
|
8
|
-
- [CSS Transition Helpers](#css-transition-helpers)
|
|
9
|
-
- [JS Integration via Delegation Model](#js-integration-via-delegation-model)
|
|
10
|
-
|
|
11
|
-
## Error Boundary
|
|
12
|
-
|
|
13
|
-
Funicular provides an `ErrorBoundary` component that catches errors from child components and displays a fallback UI instead of crashing the entire application. This is similar to React's Error Boundaries pattern.
|
|
14
|
-
|
|
15
|
-
### Basic Usage
|
|
16
|
-
|
|
17
|
-
Wrap components that might throw errors with `ErrorBoundary`:
|
|
18
|
-
|
|
19
|
-
```ruby
|
|
20
|
-
class MyApp < Funicular::Component
|
|
21
|
-
def render
|
|
22
|
-
div do
|
|
23
|
-
h1 { "My Application" }
|
|
24
|
-
|
|
25
|
-
# If RiskyComponent throws, only this section shows error UI
|
|
26
|
-
component(Funicular::ErrorBoundary) do
|
|
27
|
-
component(RiskyComponent)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# This component continues to work normally
|
|
31
|
-
component(SafeComponent)
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
### Custom Fallback UI
|
|
38
|
-
|
|
39
|
-
Provide a custom fallback using the `fallback` prop:
|
|
40
|
-
|
|
41
|
-
```ruby
|
|
42
|
-
component(Funicular::ErrorBoundary,
|
|
43
|
-
fallback: ->(error) {
|
|
44
|
-
div(style: 'background: #fee; padding: 20px; border: 2px solid #f88;') do
|
|
45
|
-
h3 { "Oops! Something went wrong" }
|
|
46
|
-
p { "Error: #{error.message}" }
|
|
47
|
-
button(onclick: -> { Funicular.router.navigate('/') }) { "Go Home" }
|
|
48
|
-
end
|
|
49
|
-
}
|
|
50
|
-
) do
|
|
51
|
-
component(RiskyComponent)
|
|
52
|
-
end
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
The fallback receives the error object as an argument.
|
|
56
|
-
|
|
57
|
-
### Error Callback
|
|
58
|
-
|
|
59
|
-
Use `on_error` to log or report errors to an external service:
|
|
60
|
-
|
|
61
|
-
```ruby
|
|
62
|
-
component(Funicular::ErrorBoundary,
|
|
63
|
-
on_error: ->(error, info) {
|
|
64
|
-
puts "Error in #{info[:component_class]}: #{error.message}"
|
|
65
|
-
|
|
66
|
-
# Send to error tracking service
|
|
67
|
-
ErrorTracker.report({
|
|
68
|
-
message: error.message,
|
|
69
|
-
component: info[:component_class],
|
|
70
|
-
props: info[:props],
|
|
71
|
-
stack_trace: error.backtrace.join("\n")
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
) do
|
|
75
|
-
component(RiskyComponent)
|
|
76
|
-
end
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
The `on_error` callback receives:
|
|
80
|
-
- `error`: The error object
|
|
81
|
-
- `info`: Hash with `{ component_class:, props: }`
|
|
82
|
-
|
|
83
|
-
### Isolating Failures
|
|
84
|
-
|
|
85
|
-
Use multiple ErrorBoundaries to isolate failures. One component's error won't affect others:
|
|
86
|
-
|
|
87
|
-
```ruby
|
|
88
|
-
class Dashboard < Funicular::Component
|
|
89
|
-
def render
|
|
90
|
-
div(style: 'display: flex; gap: 20px;') do
|
|
91
|
-
# Each section is isolated
|
|
92
|
-
component(Funicular::ErrorBoundary,
|
|
93
|
-
fallback: ->(e) { div(class: "error") { "Widget A failed" } }
|
|
94
|
-
) do
|
|
95
|
-
component(WidgetA)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
component(Funicular::ErrorBoundary,
|
|
99
|
-
fallback: ->(e) { div(class: "error") { "Widget B failed" } }
|
|
100
|
-
) do
|
|
101
|
-
component(WidgetB) # If this fails, WidgetA and WidgetC still work
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
component(Funicular::ErrorBoundary,
|
|
105
|
-
fallback: ->(e) { div(class: "error") { "Widget C failed" } }
|
|
106
|
-
) do
|
|
107
|
-
component(WidgetC)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
### Default Fallback UI
|
|
115
|
-
|
|
116
|
-
When no custom fallback is provided, ErrorBoundary displays a styled error message:
|
|
117
|
-
|
|
118
|
-
```
|
|
119
|
-
┌─────────────────────────────────────┐
|
|
120
|
-
│ ⚠ Error │
|
|
121
|
-
│ │
|
|
122
|
-
│ RuntimeError: Something went wrong │
|
|
123
|
-
│ │
|
|
124
|
-
│ Component: RiskyComponent │
|
|
125
|
-
└─────────────────────────────────────┘
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### Best Practices
|
|
129
|
-
|
|
130
|
-
**1. Place Boundaries Strategically**
|
|
131
|
-
|
|
132
|
-
```ruby
|
|
133
|
-
# ✅ Good: Boundary around independent features
|
|
134
|
-
component(ErrorBoundary) { component(UserProfile) }
|
|
135
|
-
component(ErrorBoundary) { component(CommentList) }
|
|
136
|
-
|
|
137
|
-
# ❌ Avoid: Boundary around entire app (too broad)
|
|
138
|
-
component(ErrorBoundary) do
|
|
139
|
-
component(EntireApplication)
|
|
140
|
-
end
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
**2. Provide Recovery Actions**
|
|
144
|
-
|
|
145
|
-
```ruby
|
|
146
|
-
component(ErrorBoundary,
|
|
147
|
-
fallback: ->(error) {
|
|
148
|
-
div do
|
|
149
|
-
p { "Failed to load data" }
|
|
150
|
-
button(onclick: -> { reload_component }) { "Try Again" }
|
|
151
|
-
button(onclick: -> { Funicular.router.navigate('/') }) { "Go Home" }
|
|
152
|
-
end
|
|
153
|
-
}
|
|
154
|
-
)
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
**3. Log Errors**
|
|
158
|
-
|
|
159
|
-
```ruby
|
|
160
|
-
component(ErrorBoundary,
|
|
161
|
-
on_error: ->(error, info) {
|
|
162
|
-
# Always log errors for debugging
|
|
163
|
-
puts "[ERROR] #{info[:component_class]}: #{error.message}"
|
|
164
|
-
puts error.backtrace.join("\n")
|
|
165
|
-
}
|
|
166
|
-
)
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
## CSS Transition Helpers
|
|
170
|
-
|
|
171
|
-
Funicular provides built-in CSS transition helpers to add smooth animations when elements enter or leave the DOM. These helpers leverage native CSS transitions for GPU-accelerated performance.
|
|
172
|
-
|
|
173
|
-
### Adding Elements with Animation
|
|
174
|
-
|
|
175
|
-
Use `add_via` to smoothly fade in new elements:
|
|
176
|
-
|
|
177
|
-
```ruby
|
|
178
|
-
class MessageComponent < Funicular::Component
|
|
179
|
-
def component_mounted
|
|
180
|
-
add_via(
|
|
181
|
-
"message-#{props[:message]['id']}", # Element ID
|
|
182
|
-
"opacity-0 scale-95", # from: invisible, slightly smaller
|
|
183
|
-
"opacity-100 scale-100", # to: visible, normal size
|
|
184
|
-
duration: 300 # Animation duration in ms
|
|
185
|
-
)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def render
|
|
189
|
-
div(class: "opacity-0 scale-95 transition-all", id: "message-#{props[:message]['id']}") do
|
|
190
|
-
p { props[:message]["content"] }
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
### Removing Elements with Animation
|
|
197
|
-
|
|
198
|
-
Use `remove_via` to smoothly fade out elements before removing them from state:
|
|
199
|
-
|
|
200
|
-
```ruby
|
|
201
|
-
class ChatComponent < Funicular::Component
|
|
202
|
-
def handle_message_delete(message_id)
|
|
203
|
-
remove_via(
|
|
204
|
-
"message-#{message_id}",
|
|
205
|
-
"opacity-100 max-h-screen", # from: visible, full height
|
|
206
|
-
"opacity-0 max-h-0", # to: invisible, collapsed
|
|
207
|
-
duration: 500
|
|
208
|
-
) do
|
|
209
|
-
# Callback: update state after animation completes
|
|
210
|
-
updated_messages = state.messages.reject { |m| m["id"] == message_id }
|
|
211
|
-
patch(messages: updated_messages)
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def render
|
|
216
|
-
state.messages.map do |message|
|
|
217
|
-
div(
|
|
218
|
-
id: "message-#{message['id']}",
|
|
219
|
-
class: "opacity-100 max-h-screen transition-all"
|
|
220
|
-
) do
|
|
221
|
-
span { message["content"] }
|
|
222
|
-
button(onclick: -> { handle_message_delete(message["id"]) }) { "Delete" }
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### How It Works
|
|
230
|
-
|
|
231
|
-
1. **String-based Class Specification**: CSS classes are specified as space-separated strings (e.g., `"opacity-0 scale-95"`)
|
|
232
|
-
2. **From-To Transitions**: The first string is the starting state, the second is the ending state
|
|
233
|
-
3. **Callback Support**: Optional blocks execute after animations complete
|
|
234
|
-
4. **Native CSS**: Uses browser CSS transitions for smooth, GPU-accelerated animations
|
|
235
|
-
|
|
236
|
-
### CSS Setup
|
|
237
|
-
|
|
238
|
-
Define your transitions using Funicular's Styles DSL or regular CSS:
|
|
239
|
-
|
|
240
|
-
```ruby
|
|
241
|
-
class MessageComponent < Funicular::Component
|
|
242
|
-
styles do
|
|
243
|
-
message "transition-[opacity,max-height,transform] duration-500 ease-out max-h-screen"
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def render
|
|
247
|
-
div(class: "#{s.message} opacity-0 scale-95", id: "message-#{props[:message]['id']}") do
|
|
248
|
-
# Message content
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
Or with plain CSS:
|
|
255
|
-
|
|
256
|
-
```css
|
|
257
|
-
.message {
|
|
258
|
-
transition: opacity 500ms ease-out,
|
|
259
|
-
max-height 500ms ease-out,
|
|
260
|
-
transform 500ms ease-out;
|
|
261
|
-
max-height: 100vh;
|
|
262
|
-
}
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
### Common Animation Patterns
|
|
266
|
-
|
|
267
|
-
**Fade in from below**:
|
|
268
|
-
```ruby
|
|
269
|
-
add_via(element_id, "opacity-0 translate-y-4", "opacity-100 translate-y-0", duration: 300)
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
**Slide out to the right**:
|
|
273
|
-
```ruby
|
|
274
|
-
remove_via(element_id, "translate-x-0 opacity-100", "translate-x-full opacity-0", duration: 400)
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
**Scale and fade**:
|
|
278
|
-
```ruby
|
|
279
|
-
add_via(element_id, "opacity-0 scale-50", "opacity-100 scale-100", duration: 200)
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
**Collapse height**:
|
|
283
|
-
```ruby
|
|
284
|
-
remove_via(element_id, "max-h-screen opacity-100", "max-h-0 opacity-0", duration: 500)
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
### Timing Functions
|
|
288
|
-
|
|
289
|
-
Specify different timing functions via CSS:
|
|
290
|
-
|
|
291
|
-
```ruby
|
|
292
|
-
styles do
|
|
293
|
-
# Ease out (default)
|
|
294
|
-
fade_out "transition-opacity duration-300 ease-out"
|
|
295
|
-
|
|
296
|
-
# Spring effect
|
|
297
|
-
bounce_in "transition-transform duration-500 ease-[cubic-bezier(0.68,-0.55,0.265,1.55)]"
|
|
298
|
-
|
|
299
|
-
# Linear
|
|
300
|
-
slide "transition-transform duration-400 linear"
|
|
301
|
-
end
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
### Complete Example
|
|
305
|
-
|
|
306
|
-
```ruby
|
|
307
|
-
class NotificationList < Funicular::Component
|
|
308
|
-
styles do
|
|
309
|
-
notification "transition-all duration-500 ease-out max-h-24 overflow-hidden"
|
|
310
|
-
notification_enter "opacity-0 translate-x-full"
|
|
311
|
-
notification_visible "opacity-100 translate-x-0"
|
|
312
|
-
notification_exit "opacity-0 max-h-0 translate-x-full"
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def initialize_state
|
|
316
|
-
{ notifications: [] }
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def add_notification(message)
|
|
320
|
-
notification = {
|
|
321
|
-
id: Time.now.to_i,
|
|
322
|
-
message: message,
|
|
323
|
-
timestamp: Time.now
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
patch(notifications: state.notifications + [notification])
|
|
327
|
-
|
|
328
|
-
# Animate in after a brief delay (allows element to render)
|
|
329
|
-
JS.global.setTimeout(50) do
|
|
330
|
-
add_via(
|
|
331
|
-
"notification-#{notification[:id]}",
|
|
332
|
-
s.notification_enter,
|
|
333
|
-
s.notification_visible,
|
|
334
|
-
duration: 500
|
|
335
|
-
)
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
# Auto-remove after 5 seconds
|
|
339
|
-
JS.global.setTimeout(5000) do
|
|
340
|
-
remove_notification(notification[:id])
|
|
341
|
-
end
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
def remove_notification(id)
|
|
345
|
-
remove_via(
|
|
346
|
-
"notification-#{id}",
|
|
347
|
-
s.notification_visible,
|
|
348
|
-
s.notification_exit,
|
|
349
|
-
duration: 500
|
|
350
|
-
) do
|
|
351
|
-
patch(notifications: state.notifications.reject { |n| n[:id] == id })
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
def render
|
|
356
|
-
div(class: "notification-container fixed top-4 right-4 space-y-2") do
|
|
357
|
-
state.notifications.map do |notification|
|
|
358
|
-
div(
|
|
359
|
-
id: "notification-#{notification[:id]}",
|
|
360
|
-
key: notification[:id],
|
|
361
|
-
class: "#{s.notification} #{s.notification_enter} bg-blue-600 text-white p-4 rounded shadow-lg"
|
|
362
|
-
) do
|
|
363
|
-
p { notification[:message] }
|
|
364
|
-
button(
|
|
365
|
-
onclick: -> { remove_notification(notification[:id]) },
|
|
366
|
-
class: "text-white underline text-sm"
|
|
367
|
-
) { "Dismiss" }
|
|
368
|
-
end
|
|
369
|
-
end
|
|
370
|
-
end
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
## JS Integration via Delegation Model
|
|
376
|
-
|
|
377
|
-
For complex UI interactions or data visualizations requiring existing JavaScript libraries (e.g., Chart.js, D3.js), Funicular adopts a "delegation model."
|
|
378
|
-
|
|
379
|
-
### The Delegation Model
|
|
380
|
-
|
|
381
|
-
This strategy:
|
|
382
|
-
1. **Define container elements** in Ruby components using `ref` attributes
|
|
383
|
-
2. **Delegate control** to external JavaScript during lifecycle hooks (like `component_mounted`)
|
|
384
|
-
3. **Maintain separation** between Ruby (component structure, data flow) and JavaScript (DOM manipulation, library-specific logic)
|
|
385
|
-
|
|
386
|
-
### Basic Example: Chart.js Integration
|
|
387
|
-
|
|
388
|
-
```ruby
|
|
389
|
-
class ChartComponent < Funicular::Component
|
|
390
|
-
def component_mounted
|
|
391
|
-
# Get the canvas element via ref
|
|
392
|
-
canvas = refs[:chart_canvas]
|
|
393
|
-
|
|
394
|
-
# Prepare chart data
|
|
395
|
-
chart_data = {
|
|
396
|
-
labels: state.labels,
|
|
397
|
-
datasets: [{
|
|
398
|
-
label: 'Sales',
|
|
399
|
-
data: state.data,
|
|
400
|
-
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
|
401
|
-
borderColor: 'rgba(54, 162, 235, 1)',
|
|
402
|
-
borderWidth: 1
|
|
403
|
-
}]
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
# Delegate to Chart.js
|
|
407
|
-
@chart = JS.global.Chart.new(canvas, {
|
|
408
|
-
type: 'bar',
|
|
409
|
-
data: chart_data,
|
|
410
|
-
options: {
|
|
411
|
-
responsive: true,
|
|
412
|
-
maintainAspectRatio: false
|
|
413
|
-
}
|
|
414
|
-
})
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
def component_unmounted
|
|
418
|
-
# Clean up: destroy the chart
|
|
419
|
-
@chart&.destroy()
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
def render
|
|
423
|
-
div(class: "chart-container", style: "height: 400px") do
|
|
424
|
-
canvas(ref: :chart_canvas)
|
|
425
|
-
end
|
|
426
|
-
end
|
|
427
|
-
end
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
### File Upload Integration
|
|
431
|
-
|
|
432
|
-
Use `Funicular::FileUpload` for file uploads:
|
|
433
|
-
|
|
434
|
-
```ruby
|
|
435
|
-
class ProfilePictureUpload < Funicular::Component
|
|
436
|
-
def initialize_state
|
|
437
|
-
{ uploading: false, image_url: nil, error: nil }
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
def component_mounted
|
|
441
|
-
# Mount the file upload helper (only once globally)
|
|
442
|
-
Funicular::FileUpload.mount
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
def handle_file_select(event)
|
|
446
|
-
file = event.target[:files][0]
|
|
447
|
-
return unless file
|
|
448
|
-
|
|
449
|
-
patch(uploading: true, error: nil)
|
|
450
|
-
|
|
451
|
-
# Use the FileUpload helper
|
|
452
|
-
Funicular::FileUpload.upload(file) do |result|
|
|
453
|
-
if result[:error]
|
|
454
|
-
patch(uploading: false, error: result[:error])
|
|
455
|
-
else
|
|
456
|
-
patch(
|
|
457
|
-
uploading: false,
|
|
458
|
-
image_url: result[:url],
|
|
459
|
-
error: nil
|
|
460
|
-
)
|
|
461
|
-
end
|
|
462
|
-
end
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
def render
|
|
466
|
-
div do
|
|
467
|
-
input(
|
|
468
|
-
type: "file",
|
|
469
|
-
accept: "image/*",
|
|
470
|
-
onchange: :handle_file_select
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
if state.uploading
|
|
474
|
-
p { "Uploading..." }
|
|
475
|
-
elsif state.image_url
|
|
476
|
-
img(src: state.image_url, class: "w-32 h-32")
|
|
477
|
-
elsif state.error
|
|
478
|
-
p(class: "text-red-600") { "Error: #{state.error}" }
|
|
479
|
-
end
|
|
480
|
-
end
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
### D3.js Integration
|
|
486
|
-
|
|
487
|
-
```ruby
|
|
488
|
-
class D3GraphComponent < Funicular::Component
|
|
489
|
-
def component_mounted
|
|
490
|
-
render_graph
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
def component_updated(prev_state)
|
|
494
|
-
# Re-render graph when data changes
|
|
495
|
-
if prev_state[:data] != state.data
|
|
496
|
-
render_graph
|
|
497
|
-
end
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
def render_graph
|
|
501
|
-
container = refs[:graph_container]
|
|
502
|
-
|
|
503
|
-
# Clear existing SVG
|
|
504
|
-
container.innerHTML = ""
|
|
505
|
-
|
|
506
|
-
# Get D3 from global scope
|
|
507
|
-
d3 = JS.global.d3
|
|
508
|
-
|
|
509
|
-
# Create SVG
|
|
510
|
-
svg = d3.select(container)
|
|
511
|
-
.append("svg")
|
|
512
|
-
.attr("width", 600)
|
|
513
|
-
.attr("height", 400)
|
|
514
|
-
|
|
515
|
-
# Bind data and create elements
|
|
516
|
-
circles = svg.selectAll("circle")
|
|
517
|
-
.data(state.data)
|
|
518
|
-
.enter()
|
|
519
|
-
.append("circle")
|
|
520
|
-
.attr("cx", ->(d, i) { i * 50 + 25 })
|
|
521
|
-
.attr("cy", 200)
|
|
522
|
-
.attr("r", ->(d) { d * 10 })
|
|
523
|
-
.attr("fill", "steelblue")
|
|
524
|
-
|
|
525
|
-
# Add transitions
|
|
526
|
-
circles.transition()
|
|
527
|
-
.duration(1000)
|
|
528
|
-
.attr("r", ->(d) { d * 15 })
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
def render
|
|
532
|
-
div(class: "graph-container") do
|
|
533
|
-
h3 { "D3 Visualization" }
|
|
534
|
-
div(ref: :graph_container)
|
|
535
|
-
end
|
|
536
|
-
end
|
|
537
|
-
end
|
|
538
|
-
```
|
|
539
|
-
|
|
540
|
-
### Custom JavaScript Initialization
|
|
541
|
-
|
|
542
|
-
For custom JavaScript code:
|
|
543
|
-
|
|
544
|
-
```ruby
|
|
545
|
-
class MapComponent < Funicular::Component
|
|
546
|
-
def component_mounted
|
|
547
|
-
init_map
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
def init_map
|
|
551
|
-
container = refs[:map_container]
|
|
552
|
-
|
|
553
|
-
# Call custom JavaScript function
|
|
554
|
-
JS.global.initializeMap(container, {
|
|
555
|
-
center: { lat: props[:lat], lng: props[:lng] },
|
|
556
|
-
zoom: props[:zoom] || 10,
|
|
557
|
-
markers: props[:markers] || []
|
|
558
|
-
})
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
def component_unmounted
|
|
562
|
-
# Clean up map instance
|
|
563
|
-
JS.global.destroyMap(refs[:map_container])
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
def render
|
|
567
|
-
div(ref: :map_container, style: "width: 100%; height: 500px;")
|
|
568
|
-
end
|
|
569
|
-
end
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
### Best Practices
|
|
573
|
-
|
|
574
|
-
**1. Use Refs for Delegation**
|
|
575
|
-
|
|
576
|
-
```ruby
|
|
577
|
-
# ✅ Good: Use refs to identify delegation targets
|
|
578
|
-
canvas(ref: :chart_canvas)
|
|
579
|
-
div(ref: :map_container)
|
|
580
|
-
|
|
581
|
-
# ❌ Avoid: Relying on class/id selectors
|
|
582
|
-
canvas(id: "my-chart") # Fragile, might conflict
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
**2. Clean Up in component_unmounted**
|
|
586
|
-
|
|
587
|
-
```ruby
|
|
588
|
-
def component_mounted
|
|
589
|
-
@chart = JS.global.Chart.new(...)
|
|
590
|
-
@interval = JS.global.setInterval(1000) { update_data }
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
def component_unmounted
|
|
594
|
-
@chart&.destroy() # Destroy chart
|
|
595
|
-
JS.global.clearInterval(@interval) # Clear timers
|
|
596
|
-
end
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
**3. Keep JS Logic Minimal**
|
|
600
|
-
|
|
601
|
-
```ruby
|
|
602
|
-
# ✅ Good: Ruby handles data, JS handles rendering
|
|
603
|
-
def render_chart
|
|
604
|
-
@chart.data.datasets[0].data = state.chart_data # Update from Ruby
|
|
605
|
-
@chart.update()
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
# ❌ Avoid: Complex logic in JS
|
|
609
|
-
# Keep business logic in Ruby, use JS only for rendering
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
**4. Handle Updates Properly**
|
|
613
|
-
|
|
614
|
-
```ruby
|
|
615
|
-
def component_updated(prev_state)
|
|
616
|
-
# Only update JS library when relevant data changes
|
|
617
|
-
if prev_state[:chart_data] != state.chart_data
|
|
618
|
-
update_chart(state.chart_data)
|
|
619
|
-
end
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
def update_chart(new_data)
|
|
623
|
-
@chart.data.datasets[0].data = new_data
|
|
624
|
-
@chart.update()
|
|
625
|
-
end
|
|
626
|
-
```
|
|
627
|
-
|
|
628
|
-
### See Also
|
|
629
|
-
|
|
630
|
-
For more details on JavaScript interoperability:
|
|
631
|
-
- [picoruby-wasm/docs/callback.md](../../picoruby-wasm/docs/callback.md) - Callback system
|
|
632
|
-
- [picoruby-wasm/docs/interoperability_between_js_and_ruby.md](../../picoruby-wasm/docs/interoperability_between_js_and_ruby.md) - JS::Bridge for converting Ruby objects to JS
|