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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- 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
|
+
```
|