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
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Routing and Navigation
|
|
2
|
+
|
|
3
|
+
This guide covers Funicular's built-in client-side routing and the Rails-style `link_to` helper for navigation.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Router Setup](#router-setup)
|
|
8
|
+
- [Defining Routes](#defining-routes)
|
|
9
|
+
- [URL Parameters](#url-parameters)
|
|
10
|
+
- [Named Routes and URL Helpers](#named-routes-and-url-helpers)
|
|
11
|
+
- [Programmatic Navigation](#programmatic-navigation)
|
|
12
|
+
- [link_to Helper](#link_to-helper)
|
|
13
|
+
- [Route Constraints](#route-constraints)
|
|
14
|
+
- [Best Practices](#best-practices)
|
|
15
|
+
|
|
16
|
+
## Router Setup
|
|
17
|
+
|
|
18
|
+
Initialize the router when starting your application:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
Funicular.start(container: 'app') do |router|
|
|
22
|
+
router.get('/login', to: LoginComponent, as: 'login')
|
|
23
|
+
router.get('/dashboard', to: DashboardComponent, as: 'dashboard')
|
|
24
|
+
router.get('/settings', to: SettingsComponent, as: 'settings')
|
|
25
|
+
|
|
26
|
+
router.set_default('/login') # Redirect / to /login
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The router:
|
|
31
|
+
- Uses the History API for SPA navigation
|
|
32
|
+
- Listens to browser back/forward buttons
|
|
33
|
+
- Automatically mounts/unmounts components on route changes
|
|
34
|
+
|
|
35
|
+
## Defining Routes
|
|
36
|
+
|
|
37
|
+
### Basic Routes
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
router.get('/about', to: AboutComponent, as: 'about')
|
|
41
|
+
router.get('/contact', to: ContactComponent, as: 'contact')
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### HTTP Method Routes
|
|
45
|
+
|
|
46
|
+
While Funicular is client-side, you can define method-specific routes for semantic clarity:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
router.get('/posts', to: PostListComponent, as: 'posts')
|
|
50
|
+
router.post('/posts', to: CreatePostComponent, as: 'create_post')
|
|
51
|
+
router.delete('/posts/:id', to: DeletePostComponent, as: 'delete_post')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Note**: These routes are for client-side organization. For server communication, use the `link_to` helper with `method:` option (see below).
|
|
55
|
+
|
|
56
|
+
## URL Parameters
|
|
57
|
+
|
|
58
|
+
Define dynamic routes with `:param` syntax:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
router.get('/users/:id', to: UserProfileComponent, as: 'user')
|
|
62
|
+
router.get('/posts/:post_id/comments/:comment_id', to: CommentDetailComponent, as: 'comment')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Access parameters in your component via `props`:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class UserProfileComponent < Funicular::Component
|
|
69
|
+
def component_mounted
|
|
70
|
+
user_id = props[:id] # From /users/:id
|
|
71
|
+
User.find(user_id) do |user|
|
|
72
|
+
patch(user: user)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render
|
|
77
|
+
div do
|
|
78
|
+
h1 { "User Profile: #{props[:id]}" }
|
|
79
|
+
if state.user
|
|
80
|
+
p { state.user.name }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Route Constraints
|
|
88
|
+
|
|
89
|
+
You can add regular expression constraints to URL parameters. When a constraint is specified, the route only matches if the parameter value matches the pattern. This is useful for distinguishing routes that share the same structure but accept different parameter formats.
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Funicular.start(container: 'app') do |router|
|
|
93
|
+
# Only matches when :id is numeric
|
|
94
|
+
router.get('/users/:id', to: UserProfileComponent, as: 'user', constraints: { id: /\d+/ })
|
|
95
|
+
|
|
96
|
+
# Only matches when :channel_id is numeric
|
|
97
|
+
router.get('/chat/:channel_id', to: ChatComponent, as: 'chat_channel', constraints: { channel_id: /\d+/ })
|
|
98
|
+
|
|
99
|
+
# Multiple parameters can each have constraints
|
|
100
|
+
router.get('/posts/:year/:month', to: ArchiveComponent, as: 'archive', constraints: { year: /\d{4}/, month: /\d{1,2}/ })
|
|
101
|
+
|
|
102
|
+
# No constraint - matches any string
|
|
103
|
+
router.get('/pages/:slug', to: PageComponent, as: 'page')
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The `constraints` option takes a Hash mapping parameter names (as Symbols) to Regexp objects. If a segment does not match its constraint, the router skips that route and continues to the next candidate.
|
|
108
|
+
|
|
109
|
+
**Note**: Constraints use `Regexp#match?`, which is backed by JavaScript's `RegExp` engine in PicoRuby.wasm.
|
|
110
|
+
|
|
111
|
+
## Named Routes and URL Helpers
|
|
112
|
+
|
|
113
|
+
The `as:` parameter generates URL helper methods:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
router.get('/users/:id', to: UserProfileComponent, as: 'user')
|
|
117
|
+
router.get('/posts/:id/edit', to: EditPostComponent, as: 'edit_post')
|
|
118
|
+
router.get('/settings', to: SettingsComponent, as: 'settings')
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use helpers via `RouteHelpers` module:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
include Funicular::RouteHelpers
|
|
125
|
+
|
|
126
|
+
user_path(123) # => "/users/123"
|
|
127
|
+
edit_post_path(456) # => "/posts/456/edit"
|
|
128
|
+
settings_path # => "/settings"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### With Model Objects
|
|
132
|
+
|
|
133
|
+
Helpers work with objects that respond to `#id`:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
user = User.new(id: 123, name: "Alice")
|
|
137
|
+
user_path(user) # => "/users/123"
|
|
138
|
+
|
|
139
|
+
post = Post.new(id: 456, title: "Hello")
|
|
140
|
+
edit_post_path(post) # => "/posts/456/edit"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### In Components
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class UserCard < Funicular::Component
|
|
147
|
+
include Funicular::RouteHelpers
|
|
148
|
+
|
|
149
|
+
def render
|
|
150
|
+
div do
|
|
151
|
+
h3 { props[:user].name }
|
|
152
|
+
link_to user_path(props[:user]), navigate: true do
|
|
153
|
+
span { "View Profile" }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Programmatic Navigation
|
|
161
|
+
|
|
162
|
+
Navigate imperatively using the router instance:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class LoginComponent < Funicular::Component
|
|
166
|
+
def handle_login
|
|
167
|
+
User.authenticate(state.credentials) do |user, error|
|
|
168
|
+
if user
|
|
169
|
+
# Navigate to dashboard on success
|
|
170
|
+
Funicular.router.navigate('/dashboard')
|
|
171
|
+
else
|
|
172
|
+
patch(error: error)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render
|
|
178
|
+
button(onclick: -> { handle_login }) { "Login" }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Getting Current Path
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
Funicular.router.current_path # => "/users/123"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## link_to Helper
|
|
190
|
+
|
|
191
|
+
The `link_to` helper provides Rails-style navigation and actions. It intelligently chooses between navigation (`<a>` tags) and actions (`<div>` tags) based on context.
|
|
192
|
+
|
|
193
|
+
### Navigation Links
|
|
194
|
+
|
|
195
|
+
Use `navigate: true` for client-side page transitions:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
class Navigation < Funicular::Component
|
|
199
|
+
include Funicular::RouteHelpers
|
|
200
|
+
|
|
201
|
+
def render
|
|
202
|
+
nav do
|
|
203
|
+
link_to dashboard_path, navigate: true, class: "nav-link" do
|
|
204
|
+
span { "Dashboard" }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
link_to settings_path, navigate: true, class: "nav-link" do
|
|
208
|
+
span { "Settings" }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Behavior**:
|
|
216
|
+
- Generates `<a href="/path">` tag
|
|
217
|
+
- Normal click: SPA navigation via History API (no page reload)
|
|
218
|
+
- Right-click → "Open in New Tab": Opens URL in new tab
|
|
219
|
+
- Browser features work: link preview, copy link, etc.
|
|
220
|
+
|
|
221
|
+
### Action Links
|
|
222
|
+
|
|
223
|
+
For server actions (create, update, delete), omit `navigate:`:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
class MessageItem < Funicular::Component
|
|
227
|
+
include Funicular::RouteHelpers
|
|
228
|
+
|
|
229
|
+
def render
|
|
230
|
+
div do
|
|
231
|
+
p { props[:message].content }
|
|
232
|
+
|
|
233
|
+
# DELETE request to server
|
|
234
|
+
link_to message_path(props[:message]), method: :delete, class: "delete-btn" do
|
|
235
|
+
span { "Delete" }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Behavior**:
|
|
243
|
+
- Generates `<div>` tag (not `<a>`)
|
|
244
|
+
- Sends HTTP request via Fetch API
|
|
245
|
+
- Supports all HTTP methods: `:get`, `:post`, `:put`, `:patch`, `:delete`
|
|
246
|
+
|
|
247
|
+
### Why Different Elements?
|
|
248
|
+
|
|
249
|
+
| Type | Element | Use Case | Browser Features |
|
|
250
|
+
|------|---------|----------|------------------|
|
|
251
|
+
| **Navigation** | `<a href>` | Page transitions | ✅ Right-click menu, new tab |
|
|
252
|
+
| **Action** | `<div>` | Server operations | ❌ Not a link (semantically correct) |
|
|
253
|
+
|
|
254
|
+
### Custom Response Handling
|
|
255
|
+
|
|
256
|
+
Override `handle_link_response` to customize action response handling:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class ChatComponent < Funicular::Component
|
|
260
|
+
def handle_link_response(response, path, method)
|
|
261
|
+
if response.error?
|
|
262
|
+
puts "Action failed: #{response.error_message}"
|
|
263
|
+
patch(error: response.error_message)
|
|
264
|
+
else
|
|
265
|
+
puts "Action succeeded!"
|
|
266
|
+
# Update state based on response
|
|
267
|
+
if method == :delete
|
|
268
|
+
# Remove deleted item from state
|
|
269
|
+
patch(messages: state.messages.reject { |m| m.id == response.data[:id] })
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def render
|
|
275
|
+
state.messages.map do |message|
|
|
276
|
+
link_to message_path(message), method: :delete, class: "text-red-600" do
|
|
277
|
+
span { "Delete" }
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Styling Links
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
class Navigation < Funicular::Component
|
|
288
|
+
include Funicular::RouteHelpers
|
|
289
|
+
|
|
290
|
+
styles do
|
|
291
|
+
nav_link base: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors",
|
|
292
|
+
active: "bg-blue-100 text-blue-700 font-semibold"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def render
|
|
296
|
+
nav(class: "flex gap-2") do
|
|
297
|
+
link_to dashboard_path,
|
|
298
|
+
navigate: true,
|
|
299
|
+
class: s.nav_link(is_current_path?(dashboard_path)) do
|
|
300
|
+
span { "Dashboard" }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
link_to settings_path,
|
|
304
|
+
navigate: true,
|
|
305
|
+
class: s.nav_link(is_current_path?(settings_path)) do
|
|
306
|
+
span { "Settings" }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def is_current_path?(path)
|
|
312
|
+
Funicular.router.current_path == path
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Best Practices
|
|
318
|
+
|
|
319
|
+
### 1. Use Named Routes
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
# ✅ Good: Named routes
|
|
323
|
+
router.get('/users/:id', to: UserProfileComponent, as: 'user')
|
|
324
|
+
link_to user_path(user), navigate: true
|
|
325
|
+
|
|
326
|
+
# ❌ Avoid: Hard-coded paths
|
|
327
|
+
link_to "/users/#{user.id}", navigate: true
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### 2. Choose Correct Element Type
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
# ✅ Navigation: use <a> tag
|
|
334
|
+
link_to settings_path, navigate: true do
|
|
335
|
+
span { "Settings" }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# ✅ Server action: use <div> tag
|
|
339
|
+
link_to post_path(post), method: :delete do
|
|
340
|
+
span { "Delete Post" }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# ❌ Avoid: Using navigate: true for server actions
|
|
344
|
+
link_to post_path(post), method: :delete, navigate: true # Wrong!
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### 3. Include RouteHelpers
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
# ✅ Include in components that use routing
|
|
351
|
+
class MyComponent < Funicular::Component
|
|
352
|
+
include Funicular::RouteHelpers
|
|
353
|
+
|
|
354
|
+
def render
|
|
355
|
+
link_to user_path(user), navigate: true
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### 4. Handle Loading States
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
def handle_navigation
|
|
364
|
+
patch(is_loading: true)
|
|
365
|
+
|
|
366
|
+
# Load data, then navigate
|
|
367
|
+
User.find(id) do |user|
|
|
368
|
+
patch(is_loading: false, user: user)
|
|
369
|
+
Funicular.router.navigate(user_path(user))
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### 5. Validate Before Navigation
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
def handle_next_step
|
|
378
|
+
if state.form_valid?
|
|
379
|
+
Funicular.router.navigate('/step-2')
|
|
380
|
+
else
|
|
381
|
+
patch(errors: validate_form)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Advanced: Route Guards
|
|
387
|
+
|
|
388
|
+
Implement route guards by checking conditions in `component_mounted`:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
class DashboardComponent < Funicular::Component
|
|
392
|
+
def component_mounted
|
|
393
|
+
# Redirect to login if not authenticated
|
|
394
|
+
unless Session.authenticated?
|
|
395
|
+
Funicular.router.navigate('/login')
|
|
396
|
+
return
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Load dashboard data
|
|
400
|
+
load_suspense_data
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Integration with Rails Routes
|
|
406
|
+
|
|
407
|
+
If using Funicular with Rails, you can mirror Rails routes on the client:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# Rails routes.rb
|
|
411
|
+
Rails.application.routes.draw do
|
|
412
|
+
get '/dashboard', to: 'pages#dashboard'
|
|
413
|
+
resources :users
|
|
414
|
+
resources :posts do
|
|
415
|
+
resources :comments
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Funicular router
|
|
420
|
+
Funicular.start(container: 'app') do |router|
|
|
421
|
+
router.get('/dashboard', to: DashboardComponent, as: 'dashboard')
|
|
422
|
+
router.get('/users/:id', to: UserProfileComponent, as: 'user')
|
|
423
|
+
router.get('/posts/:post_id/comments/:comment_id', to: CommentDetailComponent, as: 'comment')
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
This allows you to use the same path helpers on both client and server.
|
data/docs/styling.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# CSS-in-Ruby with Styles DSL
|
|
2
|
+
|
|
3
|
+
Funicular provides a powerful CSS-in-Ruby DSL that keeps your styles organized and scoped within each component.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Basic Usage](#basic-usage)
|
|
8
|
+
- [Conditional Styles](#conditional-styles)
|
|
9
|
+
- [Variant Styles](#variant-styles)
|
|
10
|
+
- [Combining Styles](#combining-styles)
|
|
11
|
+
- [Best Practices](#best-practices)
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
Define styles using the `styles` block at the top of your component:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
class LoginComponent < Funicular::Component
|
|
19
|
+
styles do
|
|
20
|
+
container "min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
|
|
21
|
+
card "bg-white p-8 rounded-lg shadow-2xl w-96"
|
|
22
|
+
title "text-3xl font-bold text-center mb-8 text-gray-800"
|
|
23
|
+
input "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render
|
|
27
|
+
div(class: s.container) do
|
|
28
|
+
div(class: s.card) do
|
|
29
|
+
h1(class: s.title) { "Welcome" }
|
|
30
|
+
input(class: s.input, type: "text", placeholder: "Username")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Access styles via the `s` helper in your render method.
|
|
38
|
+
|
|
39
|
+
## Conditional Styles
|
|
40
|
+
|
|
41
|
+
For styles that toggle based on state (like active/inactive), use the `active:` key:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class ChannelList < Funicular::Component
|
|
45
|
+
styles do
|
|
46
|
+
channel_item base: "p-4 hover:bg-gray-700 cursor-pointer transition-colors",
|
|
47
|
+
active: "bg-gray-700 border-l-4 border-blue-500"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render
|
|
51
|
+
state.channels.map do |channel|
|
|
52
|
+
is_active = state.current_channel_id == channel.id
|
|
53
|
+
div(class: s.channel_item(is_active)) do
|
|
54
|
+
span { channel.name }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
When `is_active` is `true`, the result is `"p-4 hover:bg-gray-700 cursor-pointer transition-colors bg-gray-700 border-l-4 border-blue-500"`.
|
|
62
|
+
When `false`, only the base classes are applied.
|
|
63
|
+
|
|
64
|
+
## Variant Styles
|
|
65
|
+
|
|
66
|
+
For styles with multiple states (e.g., primary/danger buttons), use `variants:`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class ButtonComponent < Funicular::Component
|
|
70
|
+
styles do
|
|
71
|
+
button base: "px-4 py-2 rounded font-semibold transition-colors",
|
|
72
|
+
variants: {
|
|
73
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
|
74
|
+
danger: "bg-red-600 text-white hover:bg-red-700",
|
|
75
|
+
secondary: "bg-gray-600 text-white hover:bg-gray-700",
|
|
76
|
+
ghost: "bg-transparent border border-gray-300 hover:bg-gray-100"
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render
|
|
81
|
+
div do
|
|
82
|
+
button(class: s.button(:primary)) { "Submit" }
|
|
83
|
+
button(class: s.button(:danger)) { "Delete" }
|
|
84
|
+
button(class: s.button(:secondary)) { "Cancel" }
|
|
85
|
+
button(class: s.button(:ghost)) { "Learn More" }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
You can also combine variants with props:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class ActionButton < Funicular::Component
|
|
95
|
+
styles do
|
|
96
|
+
button base: "px-4 py-2 rounded font-semibold",
|
|
97
|
+
variants: {
|
|
98
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
|
99
|
+
danger: "bg-red-600 text-white hover:bg-red-700"
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render
|
|
104
|
+
button(class: s.button(props[:variant] || :primary)) do
|
|
105
|
+
props[:label]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Usage
|
|
111
|
+
component(ActionButton, variant: :danger, label: "Delete")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Combining Styles
|
|
115
|
+
|
|
116
|
+
Chain multiple styles together using the `|` operator:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class Sidebar < Funicular::Component
|
|
120
|
+
styles do
|
|
121
|
+
flex "flex"
|
|
122
|
+
items_center "items-center"
|
|
123
|
+
gap_2 "gap-2"
|
|
124
|
+
sidebar "w-64 bg-gray-800 text-white p-4"
|
|
125
|
+
channel_item base: "p-2 rounded hover:bg-gray-700 cursor-pointer",
|
|
126
|
+
active: "bg-gray-700"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render
|
|
130
|
+
div(class: s.sidebar) do
|
|
131
|
+
# Combine utility classes
|
|
132
|
+
div(class: s.flex | s.items_center | s.gap_2) do
|
|
133
|
+
span { "Channel List" }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Mix DSL styles with custom classes
|
|
137
|
+
div(class: s.channel_item(is_active) | "mb-2" | "custom-shadow") do
|
|
138
|
+
span { "General" }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The `|` operator:
|
|
146
|
+
- Automatically filters out `nil` values
|
|
147
|
+
- Perfect for conditional styling
|
|
148
|
+
- Allows mixing DSL styles with raw strings
|
|
149
|
+
|
|
150
|
+
### Conditional Combination
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
def render
|
|
154
|
+
div(class: s.card | (state.highlighted ? "ring-2 ring-blue-500" : nil)) do
|
|
155
|
+
# Card content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Best Practices
|
|
161
|
+
|
|
162
|
+
### 1. Keep Styles Scoped
|
|
163
|
+
|
|
164
|
+
Define styles within components to avoid global namespace pollution:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# ✅ Good: Scoped to component
|
|
168
|
+
class UserCard < Funicular::Component
|
|
169
|
+
styles do
|
|
170
|
+
card "bg-white rounded shadow p-4"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ❌ Avoid: Global CSS classes scattered in render
|
|
175
|
+
class UserCard < Funicular::Component
|
|
176
|
+
def render
|
|
177
|
+
div(class: "bg-white rounded shadow p-4") # Less maintainable
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 2. Use Semantic Names
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# ✅ Good: Semantic style names
|
|
186
|
+
styles do
|
|
187
|
+
primary_button "bg-blue-600 text-white px-4 py-2 rounded"
|
|
188
|
+
danger_button "bg-red-600 text-white px-4 py-2 rounded"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ❌ Avoid: Generic or unclear names
|
|
192
|
+
styles do
|
|
193
|
+
btn1 "bg-blue-600 text-white px-4 py-2 rounded"
|
|
194
|
+
btn2 "bg-red-600 text-white px-4 py-2 rounded"
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 3. Group Related Styles
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
styles do
|
|
202
|
+
# Layout
|
|
203
|
+
container "max-w-7xl mx-auto px-4"
|
|
204
|
+
sidebar "w-64 bg-gray-800"
|
|
205
|
+
main_content "flex-1 p-6"
|
|
206
|
+
|
|
207
|
+
# Typography
|
|
208
|
+
heading "text-2xl font-bold text-gray-900"
|
|
209
|
+
subheading "text-lg font-semibold text-gray-700"
|
|
210
|
+
body_text "text-base text-gray-600"
|
|
211
|
+
|
|
212
|
+
# Buttons
|
|
213
|
+
button base: "px-4 py-2 rounded",
|
|
214
|
+
variants: {
|
|
215
|
+
primary: "bg-blue-600 text-white",
|
|
216
|
+
secondary: "bg-gray-600 text-white"
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### 4. Extract Common Styles
|
|
222
|
+
|
|
223
|
+
For styles used across multiple components, consider creating a shared module:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
module ButtonStyles
|
|
227
|
+
def self.included(base)
|
|
228
|
+
base.class_eval do
|
|
229
|
+
styles do
|
|
230
|
+
button base: "px-4 py-2 rounded font-semibold transition-colors",
|
|
231
|
+
variants: {
|
|
232
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
|
233
|
+
danger: "bg-red-600 text-white hover:bg-red-700"
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
class MyComponent < Funicular::Component
|
|
241
|
+
include ButtonStyles
|
|
242
|
+
|
|
243
|
+
def render
|
|
244
|
+
button(class: s.button(:primary)) { "Click Me" }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### 5. Use Tailwind (or Similar) for Utility Classes
|
|
250
|
+
|
|
251
|
+
Funicular's Styles DSL works great with Tailwind CSS utility classes:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
styles do
|
|
255
|
+
card "bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 hover:shadow-xl transition-shadow"
|
|
256
|
+
input "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### 6. Prefer Composition Over Long Class Strings
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
# ✅ Good: Composed styles
|
|
264
|
+
styles do
|
|
265
|
+
base_input "w-full px-3 py-2 border rounded-md"
|
|
266
|
+
focused_input "outline-none ring-2 ring-blue-500"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def render
|
|
270
|
+
input(class: s.base_input | s.focused_input)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ❌ Avoid: One massive style
|
|
274
|
+
styles do
|
|
275
|
+
input "w-full px-3 py-2 border rounded-md outline-none ring-2 ring-blue-500"
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Key Benefits
|
|
280
|
+
|
|
281
|
+
- **Scoped Styles**: Styles are defined within components, avoiding global namespace pollution
|
|
282
|
+
- **Readability**: `s.button(:primary)` is more semantic than raw Tailwind classes scattered throughout render methods
|
|
283
|
+
- **Maintainability**: Change styles in one place rather than hunting through render methods
|
|
284
|
+
- **Type Safety**: The DSL ensures style names are consistent throughout your component
|
|
285
|
+
- **Flexibility**: Mix DSL styles with raw strings when needed using the `|` operator
|