funicular 0.0.1 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
data/docs/forms.md ADDED
@@ -0,0 +1,446 @@
1
+ # Rails-style Form Builder
2
+
3
+ Funicular provides a powerful `form_for` helper that brings Rails-style form building to your Pure Ruby frontend. The FormBuilder automatically manages form state, handles two-way data binding, and displays validation errors.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Basic Usage](#basic-usage)
8
+ - [Available Field Types](#available-field-types)
9
+ - [Automatic Error Display](#automatic-error-display)
10
+ - [Nested Fields](#nested-fields)
11
+ - [Customizing Error Styles](#customizing-error-styles)
12
+ - [Form Submission](#form-submission)
13
+ - [Boolean Attributes](#boolean-attributes)
14
+ - [Examples](#examples)
15
+
16
+ ## Basic Usage
17
+
18
+ ```ruby
19
+ class SignupComponent < Funicular::Component
20
+ def initialize_state
21
+ {
22
+ user: { username: "", email: "", password: "" },
23
+ errors: {}
24
+ }
25
+ end
26
+
27
+ def handle_submit(form_data)
28
+ # Create user via O-R-M or API call
29
+ User.create(form_data) do |user, errors|
30
+ if errors
31
+ patch(errors: errors)
32
+ else
33
+ # Success: redirect or show success message
34
+ puts "User created: #{user.username}"
35
+ end
36
+ end
37
+ end
38
+
39
+ def render
40
+ form_for(:user, on_submit: :handle_submit) do |f|
41
+ f.label(:username)
42
+ f.text_field(:username, class: "form-input", autofocus: true)
43
+
44
+ f.label(:email)
45
+ f.email_field(:email, class: "form-input")
46
+
47
+ f.label(:password)
48
+ f.password_field(:password, class: "form-input")
49
+
50
+ f.submit("Sign Up", class: "btn btn-primary")
51
+ end
52
+ end
53
+ end
54
+ ```
55
+
56
+ ## Available Field Types
57
+
58
+ The FormBuilder supports all standard HTML input types:
59
+
60
+ ### Text Inputs
61
+
62
+ ```ruby
63
+ f.text_field(:username, class: "w-full p-2 border rounded")
64
+ f.password_field(:password, class: "w-full p-2 border rounded")
65
+ f.email_field(:email, class: "w-full p-2 border rounded")
66
+ f.number_field(:age, min: 0, max: 120)
67
+ ```
68
+
69
+ ### Textarea
70
+
71
+ ```ruby
72
+ f.textarea(:bio, rows: 5, class: "w-full p-2 border rounded")
73
+ ```
74
+
75
+ ### Checkbox
76
+
77
+ ```ruby
78
+ f.checkbox(:agree_to_terms, class: "mr-2")
79
+ f.label(:agree_to_terms) { "I agree to the terms and conditions" }
80
+ ```
81
+
82
+ ### Select Dropdown
83
+
84
+ ```ruby
85
+ # Simple array of options
86
+ f.select(:country, ["USA", "Canada", "UK", "Japan"], class: "w-full p-2 border rounded")
87
+
88
+ # Array of [label, value] pairs
89
+ f.select(:role, [["Administrator", "admin"], ["Editor", "editor"], ["Viewer", "viewer"]])
90
+
91
+ # With prompt
92
+ f.select(:category, ["Tech", "Science", "Art"], prompt: "Select a category")
93
+ ```
94
+
95
+ ### File Upload
96
+
97
+ ```ruby
98
+ f.file_field(:avatar, accept: "image/*", class: "w-full")
99
+ ```
100
+
101
+ ### Label
102
+
103
+ ```ruby
104
+ f.label(:username) # Auto-generates "Username" from field name
105
+ f.label(:email) { "Email Address" } # Custom label text
106
+ ```
107
+
108
+ ### Submit Button
109
+
110
+ ```ruby
111
+ f.submit("Sign Up", class: "btn btn-primary")
112
+ f.submit("Save Changes", disabled: state.is_saving)
113
+ ```
114
+
115
+ ## Automatic Error Display
116
+
117
+ The FormBuilder automatically displays validation errors when they exist in `state.errors`. Errors are displayed below their respective fields with customizable styling.
118
+
119
+ ```ruby
120
+ class LoginComponent < Funicular::Component
121
+ def initialize_state
122
+ {
123
+ user: { email: "", password: "" },
124
+ errors: {}
125
+ }
126
+ end
127
+
128
+ def handle_submit(form_data)
129
+ User.authenticate(form_data) do |user, errors|
130
+ if errors
131
+ # Errors format: { email: "Email is invalid", password: "Password is required" }
132
+ patch(errors: errors)
133
+ else
134
+ # Success: navigate to dashboard
135
+ end
136
+ end
137
+ end
138
+
139
+ def render
140
+ form_for(:user, on_submit: :handle_submit) do |f|
141
+ f.email_field(:email, class: "w-full p-2 border rounded")
142
+ # If errors[:email] exists, it's displayed in red below the field
143
+ # Field automatically gets border-red-500 class
144
+
145
+ f.password_field(:password, class: "w-full p-2 border rounded")
146
+ # If errors[:password] exists, it's displayed in red below the field
147
+
148
+ f.submit("Login")
149
+ end
150
+ end
151
+ end
152
+ ```
153
+
154
+ ### Error Behavior
155
+
156
+ When `state.errors` contains a key matching a field name:
157
+ 1. The error message is displayed below the field
158
+ 2. The field gets an error class added (default: `border-red-500`)
159
+ 3. Error messages are styled with `error_class` (default: `text-red-600 text-sm mt-1`)
160
+
161
+ ## Nested Fields
162
+
163
+ The FormBuilder supports nested fields using dot notation:
164
+
165
+ ```ruby
166
+ class UserProfileComponent < Funicular::Component
167
+ def initialize_state
168
+ {
169
+ user: {
170
+ name: "",
171
+ profile: { bio: "", location: "", website: "" },
172
+ settings: { theme: "light", notifications: true }
173
+ },
174
+ errors: {}
175
+ }
176
+ end
177
+
178
+ def render
179
+ form_for(:user, on_submit: :handle_submit) do |f|
180
+ f.text_field(:name)
181
+
182
+ # Nested profile fields
183
+ f.text_field("profile.bio")
184
+ f.text_field("profile.location")
185
+ f.text_field("profile.website")
186
+
187
+ # Nested settings fields
188
+ f.select("settings.theme", ["light", "dark"])
189
+ f.checkbox("settings.notifications")
190
+
191
+ f.submit("Save")
192
+ end
193
+ end
194
+ end
195
+ ```
196
+
197
+ Nested errors also work:
198
+
199
+ ```ruby
200
+ def handle_submit(form_data)
201
+ User.update(form_data) do |user, errors|
202
+ # Errors format: { "profile.bio": "Bio is too long", name: "Name is required" }
203
+ patch(errors: errors)
204
+ end
205
+ end
206
+ ```
207
+
208
+ ## Customizing Error Styles
209
+
210
+ ### Global Configuration
211
+
212
+ Configure error display styles globally:
213
+
214
+ ```ruby
215
+ # In your initializer (e.g., app/funicular/initializer.rb)
216
+ Funicular.configure_forms do |config|
217
+ config[:error_class] = "text-red-600 text-sm mt-1"
218
+ config[:field_error_class] = "border-red-500 border-2"
219
+ end
220
+ ```
221
+
222
+ ### Per-Form Configuration
223
+
224
+ ```ruby
225
+ form_for(:user,
226
+ on_submit: :handle_submit,
227
+ error_class: "error-text",
228
+ field_error_class: "error-border") do |f|
229
+ f.email_field(:email)
230
+ f.submit("Submit")
231
+ end
232
+ ```
233
+
234
+ ## Form Submission
235
+
236
+ ### Submit Handler
237
+
238
+ The `on_submit` callback receives the form data as a hash:
239
+
240
+ ```ruby
241
+ def handle_submit(form_data)
242
+ # form_data is the current state of the form model
243
+ # For form_for(:user), form_data = state.user
244
+
245
+ puts form_data # { username: "alice", email: "alice@example.com", password: "..." }
246
+
247
+ # Make API call
248
+ User.create(form_data) do |user, errors|
249
+ if errors
250
+ patch(errors: errors)
251
+ else
252
+ # Navigate to success page
253
+ Funicular.router.navigate('/dashboard')
254
+ end
255
+ end
256
+ end
257
+ ```
258
+
259
+ ### Preventing Default Submit
260
+
261
+ The FormBuilder automatically prevents the default form submission (which would reload the page). You don't need to call `event.preventDefault()`.
262
+
263
+ ### Submit Button State
264
+
265
+ Disable the submit button while processing:
266
+
267
+ ```ruby
268
+ def initialize_state
269
+ { user: { email: "" }, is_submitting: false }
270
+ end
271
+
272
+ def handle_submit(form_data)
273
+ patch(is_submitting: true)
274
+
275
+ User.create(form_data) do |user, errors|
276
+ patch(is_submitting: false, errors: errors || {})
277
+ end
278
+ end
279
+
280
+ def render
281
+ form_for(:user, on_submit: :handle_submit) do |f|
282
+ f.email_field(:email)
283
+ f.submit(
284
+ state.is_submitting ? "Submitting..." : "Submit",
285
+ disabled: state.is_submitting
286
+ )
287
+ end
288
+ end
289
+ ```
290
+
291
+ ## Boolean Attributes
292
+
293
+ All HTML boolean attributes are supported and can be set to `true` or `false`:
294
+
295
+ ```ruby
296
+ f.text_field(:username,
297
+ autofocus: true,
298
+ required: true,
299
+ readonly: state.is_locked,
300
+ disabled: state.is_processing
301
+ )
302
+
303
+ f.checkbox(:subscribe,
304
+ checked: state.user[:subscribe],
305
+ required: true
306
+ )
307
+
308
+ f.textarea(:comment,
309
+ autofocus: true,
310
+ required: state.is_required
311
+ )
312
+
313
+ f.submit("Send",
314
+ disabled: state.message.empty?
315
+ )
316
+ ```
317
+
318
+ Supported boolean attributes:
319
+ - `autofocus`
320
+ - `disabled`
321
+ - `checked`
322
+ - `readonly`
323
+ - `required`
324
+ - `multiple`
325
+
326
+ ## Examples
327
+
328
+ ### Complete Registration Form
329
+
330
+ ```ruby
331
+ class RegistrationComponent < Funicular::Component
332
+ def initialize_state
333
+ {
334
+ user: {
335
+ username: "",
336
+ email: "",
337
+ password: "",
338
+ password_confirmation: "",
339
+ country: "",
340
+ agree_to_terms: false
341
+ },
342
+ errors: {},
343
+ is_submitting: false
344
+ }
345
+ end
346
+
347
+ def handle_submit(form_data)
348
+ patch(is_submitting: true, errors: {})
349
+
350
+ User.create(form_data) do |user, errors|
351
+ if errors
352
+ patch(is_submitting: false, errors: errors)
353
+ else
354
+ patch(is_submitting: false)
355
+ # Navigate to success page
356
+ Funicular.router.navigate('/welcome')
357
+ end
358
+ end
359
+ end
360
+
361
+ def render
362
+ div(class: "max-w-md mx-auto p-6") do
363
+ h1(class: "text-2xl font-bold mb-6") { "Create Account" }
364
+
365
+ form_for(:user, on_submit: :handle_submit, class: "space-y-4") do |f|
366
+ div do
367
+ f.label(:username) { "Username" }
368
+ f.text_field(:username, class: "w-full p-2 border rounded", autofocus: true)
369
+ end
370
+
371
+ div do
372
+ f.label(:email) { "Email" }
373
+ f.email_field(:email, class: "w-full p-2 border rounded")
374
+ end
375
+
376
+ div do
377
+ f.label(:password) { "Password" }
378
+ f.password_field(:password, class: "w-full p-2 border rounded")
379
+ end
380
+
381
+ div do
382
+ f.label(:password_confirmation) { "Confirm Password" }
383
+ f.password_field(:password_confirmation, class: "w-full p-2 border rounded")
384
+ end
385
+
386
+ div do
387
+ f.label(:country) { "Country" }
388
+ f.select(:country, ["USA", "Canada", "UK", "Japan"], prompt: "Select country", class: "w-full p-2 border rounded")
389
+ end
390
+
391
+ div(class: "flex items-center") do
392
+ f.checkbox(:agree_to_terms, class: "mr-2")
393
+ f.label(:agree_to_terms) { "I agree to the Terms and Conditions" }
394
+ end
395
+
396
+ f.submit(
397
+ state.is_submitting ? "Creating Account..." : "Create Account",
398
+ disabled: state.is_submitting || !state.user[:agree_to_terms],
399
+ class: "w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:bg-gray-400"
400
+ )
401
+ end
402
+ end
403
+ end
404
+ end
405
+ ```
406
+
407
+ ### Form with File Upload
408
+
409
+ ```ruby
410
+ class ProfilePictureComponent < Funicular::Component
411
+ def initialize_state
412
+ { avatar_url: "", is_uploading: false }
413
+ end
414
+
415
+ def handle_file_change(event)
416
+ file = event.target[:files][0]
417
+ return unless file
418
+
419
+ patch(is_uploading: true)
420
+
421
+ # Use FileUpload helper (see JS Integration docs)
422
+ Funicular::FileUpload.upload(file) do |result|
423
+ if result[:error]
424
+ puts "Upload failed: #{result[:error]}"
425
+ patch(is_uploading: false)
426
+ else
427
+ patch(avatar_url: result[:url], is_uploading: false)
428
+ end
429
+ end
430
+ end
431
+
432
+ def render
433
+ div do
434
+ form_for(:profile) do |f|
435
+ f.file_field(:avatar, accept: "image/*", onchange: :handle_file_change)
436
+
437
+ if state.is_uploading
438
+ p { "Uploading..." }
439
+ elsif !state.avatar_url.empty?
440
+ img(src: state.avatar_url, class: "w-32 h-32 rounded-full")
441
+ end
442
+ end
443
+ end
444
+ end
445
+ end
446
+ ```