pom-component 1.0.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1037 -0
- data/Rakefile +14 -0
- data/lib/pom/component.rb +30 -0
- data/lib/pom/configuration.rb +25 -0
- data/lib/pom/engine.rb +15 -0
- data/lib/pom/helpers/option_helper.rb +70 -0
- data/lib/pom/helpers/stimulus_helper.rb +114 -0
- data/lib/pom/helpers/view_helper.rb +59 -0
- data/lib/pom/option_dsl.rb +183 -0
- data/lib/pom/styleable.rb +153 -0
- data/lib/pom/version.rb +5 -0
- data/lib/pom-component.rb +3 -0
- data/lib/pom.rb +19 -0
- metadata +145 -0
data/README.md
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
# Pom Component
|
|
2
|
+
|
|
3
|
+
A UI component toolkit for Rails with Tailwind CSS integration. Pom provides a powerful base class for building reusable ViewComponents with advanced features including option management, style composition, and Stimulus.js integration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🎨 **Styleable DSL** - Compose Tailwind CSS classes with automatic conflict resolution
|
|
8
|
+
- ⚙️ **Option DSL** - Define component options with enums, defaults, and validation
|
|
9
|
+
- 🎯 **Type Safety** - Enum validation and required option enforcement
|
|
10
|
+
- 🔄 **Inheritance** - Full support for component inheritance with style and option merging
|
|
11
|
+
- ⚡ **Stimulus Integration** - Built-in helpers for Stimulus.js data attributes
|
|
12
|
+
- 🧩 **Flexible** - Capture extra options and merge HTML attributes intelligently
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add this line to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'pom-component'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
And then execute:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install it yourself as:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gem install pom-component
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Ruby >= 3.2.0
|
|
37
|
+
- Rails >= 7.1.0
|
|
38
|
+
- ViewComponent >= 4.0
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
Create your first component by inheriting from `Pom::Component`:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# app/components/pom/button_component.rb
|
|
46
|
+
module Pom
|
|
47
|
+
class ButtonComponent < Pom::Component
|
|
48
|
+
option :variant, enums: [:primary, :secondary, :danger], default: :primary
|
|
49
|
+
option :size, enums: [:sm, :md, :lg], default: :md
|
|
50
|
+
option :disabled, default: false
|
|
51
|
+
|
|
52
|
+
define_styles(
|
|
53
|
+
base: "inline-flex items-center justify-center font-medium rounded transition",
|
|
54
|
+
variant: {
|
|
55
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
|
56
|
+
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
|
|
57
|
+
danger: "bg-red-600 text-white hover:bg-red-700"
|
|
58
|
+
},
|
|
59
|
+
size: {
|
|
60
|
+
sm: "px-3 py-1.5 text-sm",
|
|
61
|
+
md: "px-4 py-2 text-base",
|
|
62
|
+
lg: "px-6 py-3 text-lg"
|
|
63
|
+
},
|
|
64
|
+
disabled: {
|
|
65
|
+
true: "opacity-50 cursor-not-allowed pointer-events-none",
|
|
66
|
+
false: "cursor-pointer"
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def call
|
|
71
|
+
content_tag :button, content, **html_options
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def html_options
|
|
77
|
+
merge_options(
|
|
78
|
+
{ class: styles_for(variant: variant, size: size, disabled: disabled) },
|
|
79
|
+
extra_options
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use it in your views:
|
|
87
|
+
|
|
88
|
+
```erb
|
|
89
|
+
<%# app/views/pages/index.html.erb %>
|
|
90
|
+
<%= render Pom::ButtonComponent.new(variant: :primary, size: :lg) do %>
|
|
91
|
+
Click me!
|
|
92
|
+
<% end %>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or using the helper method (component must be in the `Pom::` namespace):
|
|
96
|
+
|
|
97
|
+
```erb
|
|
98
|
+
<%# This looks for Pom::ButtonComponent %>
|
|
99
|
+
<%= pom_button(variant: :danger, disabled: true) do %>
|
|
100
|
+
Delete
|
|
101
|
+
<% end %>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Note:** The `pom_*` helper methods only work with components defined in the `Pom::` namespace. See [Configuration](#configuration) to learn how to add custom prefixes for other namespaces.
|
|
105
|
+
|
|
106
|
+
## Component Crafting Guide
|
|
107
|
+
|
|
108
|
+
For comprehensive examples and best practices on building components from basic to complex compositions, see the [Component Crafting Guide](COMPONENT_CRAFTING.md).
|
|
109
|
+
|
|
110
|
+
## Option DSL
|
|
111
|
+
|
|
112
|
+
The Option DSL provides a declarative way to define component options with validation, defaults, and type safety.
|
|
113
|
+
|
|
114
|
+
### Basic Usage
|
|
115
|
+
|
|
116
|
+
Define options using the `option` class method:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class CardComponent < Pom::Component
|
|
120
|
+
option :title
|
|
121
|
+
option :variant, enums: [:default, :bordered, :elevated]
|
|
122
|
+
option :padding, default: :md
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Option Parameters
|
|
127
|
+
|
|
128
|
+
#### `enums:`
|
|
129
|
+
|
|
130
|
+
Restrict option values to a specific set:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
option :size, enums: [:sm, :md, :lg]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This will:
|
|
137
|
+
|
|
138
|
+
- Validate values on initialization and when using setters
|
|
139
|
+
- Accept both symbols and strings (automatically converted to symbols)
|
|
140
|
+
- Raise `ArgumentError` for invalid values
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# Valid
|
|
144
|
+
component = MyComponent.new(size: :md)
|
|
145
|
+
component = MyComponent.new(size: "lg")
|
|
146
|
+
|
|
147
|
+
# Invalid - raises ArgumentError
|
|
148
|
+
component = MyComponent.new(size: :xl)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### `default:`
|
|
152
|
+
|
|
153
|
+
Provide a default value when the option is not specified:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
option :color, default: :blue
|
|
157
|
+
option :count, default: 0
|
|
158
|
+
option :timestamp, default: -> { Time.current }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Defaults can be:
|
|
162
|
+
|
|
163
|
+
- **Static values**: Strings, symbols, numbers, booleans
|
|
164
|
+
- **Procs/Lambdas**: Called at runtime for dynamic defaults
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class TimestampComponent < Pom::Component
|
|
168
|
+
option :created_at, default: -> { Time.current }
|
|
169
|
+
option :format, default: "%Y-%m-%d"
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
#### `required:`
|
|
174
|
+
|
|
175
|
+
Mark an option as required:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
option :user_id, required: true
|
|
179
|
+
option :status, required: true, default: :active
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Notes:
|
|
183
|
+
|
|
184
|
+
- Required options without defaults must be provided during initialization
|
|
185
|
+
- Required options with defaults don't raise errors (the default satisfies the requirement)
|
|
186
|
+
- Missing required options raise `ArgumentError`
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
class UserCardComponent < Pom::Component
|
|
190
|
+
option :name, required: true
|
|
191
|
+
option :email, required: true
|
|
192
|
+
option :role, required: true, default: :member
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Valid
|
|
196
|
+
UserCardComponent.new(name: "John", email: "john@example.com")
|
|
197
|
+
|
|
198
|
+
# Invalid - raises ArgumentError: Missing required option: name
|
|
199
|
+
UserCardComponent.new(email: "john@example.com")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Generated Methods
|
|
203
|
+
|
|
204
|
+
For each option, three methods are automatically generated:
|
|
205
|
+
|
|
206
|
+
#### Getter Method
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
component.variant # => :primary
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### Setter Method (with validation)
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
component.variant = :secondary
|
|
216
|
+
component.size = :invalid # => ArgumentError if enums are defined
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### Predicate Method
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
component.variant? # => true if variant is present
|
|
223
|
+
component.title? # => false if title is nil or empty
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Extra Options
|
|
227
|
+
|
|
228
|
+
Any options not explicitly defined are captured in `extra_options`:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class MyComponent < Pom::Component
|
|
232
|
+
option :title
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
component = MyComponent.new(title: "Hello", data: { controller: "modal" }, id: "my-modal")
|
|
236
|
+
|
|
237
|
+
component.title # => "Hello"
|
|
238
|
+
component.extra_options # => { data: { controller: "modal" }, id: "my-modal" }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
This is useful for passing through HTML attributes:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
def call
|
|
245
|
+
content_tag :div, content, **extra_options
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Class Methods
|
|
250
|
+
|
|
251
|
+
Query option metadata at the class level:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
MyComponent.enum_values_for(:variant) # => [:primary, :secondary, :danger]
|
|
255
|
+
MyComponent.default_value_for(:size) # => :md
|
|
256
|
+
MyComponent.required_options # => [:user_id, :title]
|
|
257
|
+
MyComponent.optional_options # => [:variant, :size, :color]
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Instance Methods
|
|
261
|
+
|
|
262
|
+
Query and manipulate option values:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# Get all option values as a hash
|
|
266
|
+
component.option_values
|
|
267
|
+
# => { variant: :primary, size: :md, disabled: false }
|
|
268
|
+
|
|
269
|
+
# Check if an option was explicitly set
|
|
270
|
+
component.option_set?(:variant) # => true
|
|
271
|
+
component.option_set?(:size) # => false (using default)
|
|
272
|
+
|
|
273
|
+
# Reset an option to its default value
|
|
274
|
+
component.reset_option(:variant)
|
|
275
|
+
component.variant # => :primary (default)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Inheritance
|
|
279
|
+
|
|
280
|
+
Options are inherited and can be extended:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
class BaseButton < Pom::Component
|
|
284
|
+
option :size, enums: [:sm, :md, :lg], default: :md
|
|
285
|
+
option :disabled, default: false
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
class IconButton < BaseButton
|
|
289
|
+
option :icon, required: true
|
|
290
|
+
option :icon_position, enums: [:left, :right], default: :left
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# IconButton has all options: size, disabled, icon, icon_position
|
|
294
|
+
button = IconButton.new(icon: "star", size: :lg)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Styleable
|
|
298
|
+
|
|
299
|
+
The Styleable module provides a powerful DSL for composing Tailwind CSS classes with automatic conflict resolution using the `tailwind_merge` gem.
|
|
300
|
+
|
|
301
|
+
### Basic Usage
|
|
302
|
+
|
|
303
|
+
Define styles using the `define_styles` class method:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
class AlertComponent < Pom::Component
|
|
307
|
+
option :variant, enums: [:info, :success, :warning, :error], default: :info
|
|
308
|
+
|
|
309
|
+
define_styles(
|
|
310
|
+
base: "p-4 rounded-lg border",
|
|
311
|
+
variant: {
|
|
312
|
+
info: "bg-blue-50 border-blue-200 text-blue-800",
|
|
313
|
+
success: "bg-green-50 border-green-200 text-green-800",
|
|
314
|
+
warning: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
|
315
|
+
error: "bg-red-50 border-red-200 text-red-800"
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def call
|
|
320
|
+
content_tag :div, content, class: styles_for(variant: variant)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Style Structure
|
|
326
|
+
|
|
327
|
+
Styles are organized into **keys** that map to option values:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
define_styles(
|
|
331
|
+
base: "always-applied-classes",
|
|
332
|
+
option_name: {
|
|
333
|
+
option_value_1: "classes-for-value-1",
|
|
334
|
+
option_value_2: "classes-for-value-2"
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
#### Base Styles
|
|
340
|
+
|
|
341
|
+
Base styles are always applied:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
define_styles(
|
|
345
|
+
base: "font-sans antialiased"
|
|
346
|
+
)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Base styles can also be a hash for organization:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
define_styles(
|
|
353
|
+
base: {
|
|
354
|
+
default: "component rounded-lg",
|
|
355
|
+
hover: "hover:shadow-md",
|
|
356
|
+
focus: "focus:ring-2 focus:ring-blue-500"
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
All values in a hash are concatenated and applied.
|
|
362
|
+
|
|
363
|
+
#### Variant Styles
|
|
364
|
+
|
|
365
|
+
Map option values to specific classes:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
define_styles(
|
|
369
|
+
variant: {
|
|
370
|
+
solid: "bg-blue-600 text-white",
|
|
371
|
+
outline: "border-2 border-blue-600 text-blue-600",
|
|
372
|
+
ghost: "text-blue-600 hover:bg-blue-50"
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Using styles_for
|
|
378
|
+
|
|
379
|
+
Generate the class string using `styles_for`:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
def call
|
|
383
|
+
content_tag :div, content, class: styles_for(variant: variant, size: size)
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The method:
|
|
388
|
+
|
|
389
|
+
1. Applies base styles
|
|
390
|
+
2. Resolves each provided option against style definitions
|
|
391
|
+
3. Concatenates all matching classes
|
|
392
|
+
4. Uses `tailwind_merge` to resolve conflicts
|
|
393
|
+
|
|
394
|
+
**Only the options you pass to `styles_for` will be applied:**
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
# Only applies base and variant styles
|
|
398
|
+
styles_for(variant: :primary)
|
|
399
|
+
|
|
400
|
+
# Applies base, variant, and size styles
|
|
401
|
+
styles_for(variant: :primary, size: :lg)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Boolean Style Keys
|
|
405
|
+
|
|
406
|
+
Handle boolean options elegantly:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
class ButtonComponent < Pom::Component
|
|
410
|
+
option :disabled, default: false
|
|
411
|
+
option :loading, default: false
|
|
412
|
+
|
|
413
|
+
define_styles(
|
|
414
|
+
base: "btn",
|
|
415
|
+
disabled: {
|
|
416
|
+
true: "opacity-50 cursor-not-allowed pointer-events-none",
|
|
417
|
+
false: "cursor-pointer hover:opacity-90"
|
|
418
|
+
},
|
|
419
|
+
loading: {
|
|
420
|
+
true: "animate-pulse",
|
|
421
|
+
false: ""
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def call
|
|
426
|
+
content_tag :button, content, class: styles_for(disabled: disabled, loading: loading)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Dynamic Styles with Lambdas
|
|
432
|
+
|
|
433
|
+
Use lambdas for dynamic style computation based on component state:
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
class BadgeComponent < Pom::Component
|
|
437
|
+
option :variant, enums: [:solid, :outline], default: :solid
|
|
438
|
+
option :color, enums: [:blue, :green, :red, :yellow], default: :blue
|
|
439
|
+
|
|
440
|
+
define_styles(
|
|
441
|
+
base: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
|
|
442
|
+
variant: {
|
|
443
|
+
solid: ->(color: :blue, **_opts) {
|
|
444
|
+
case color
|
|
445
|
+
when :blue then "bg-blue-100 text-blue-800"
|
|
446
|
+
when :green then "bg-green-100 text-green-800"
|
|
447
|
+
when :red then "bg-red-100 text-red-800"
|
|
448
|
+
when :yellow then "bg-yellow-100 text-yellow-800"
|
|
449
|
+
end
|
|
450
|
+
},
|
|
451
|
+
outline: ->(color: :blue, **_opts) {
|
|
452
|
+
case color
|
|
453
|
+
when :blue then "border border-blue-300 text-blue-700"
|
|
454
|
+
when :green then "border border-green-300 text-green-700"
|
|
455
|
+
when :red then "border border-red-300 text-red-700"
|
|
456
|
+
when :yellow then "border border-yellow-300 text-yellow-700"
|
|
457
|
+
end
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def call
|
|
463
|
+
content_tag :span, content, class: styles_for(variant: variant, color: color)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Usage
|
|
468
|
+
<%= render BadgeComponent.new(variant: :solid, color: :green) { "Active" } %>
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**Important:** Always use full Tailwind CSS class names, not string interpolation. Tailwind's JIT compiler needs to see complete class names to generate the CSS.
|
|
472
|
+
|
|
473
|
+
**How Lambda Parameters Work:**
|
|
474
|
+
|
|
475
|
+
Lambda styles receive all options passed to `styles_for` as keyword arguments. This includes both the matching key (e.g., `variant: :solid`) and any additional arbitrary parameters you provide:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# The lambda for variant: :solid receives ALL parameters
|
|
479
|
+
styles_for(variant: :solid, color: :green, size: :lg)
|
|
480
|
+
# Lambda receives: { variant: :solid, color: :green, size: :lg }
|
|
481
|
+
|
|
482
|
+
# Even custom parameters are passed through
|
|
483
|
+
styles_for(variant: :outline, foo: "bar", baz: 123)
|
|
484
|
+
# Lambda receives: { variant: :outline, foo: "bar", baz: 123 }
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
This allows lambdas to compute styles based on multiple option combinations.
|
|
488
|
+
|
|
489
|
+
### Lambda Base Styles
|
|
490
|
+
|
|
491
|
+
Base styles can also be lambdas:
|
|
492
|
+
|
|
493
|
+
```ruby
|
|
494
|
+
define_styles(
|
|
495
|
+
base: ->(disabled: false, **_opts) {
|
|
496
|
+
classes = ["component rounded-lg transition"]
|
|
497
|
+
classes << "opacity-50" if disabled
|
|
498
|
+
classes.join(" ")
|
|
499
|
+
},
|
|
500
|
+
variant: {
|
|
501
|
+
solid: "bg-blue-600 text-white",
|
|
502
|
+
outline: "border-2 border-blue-600"
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Style Groups
|
|
508
|
+
|
|
509
|
+
Organize styles for different parts of your component:
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
class ModalComponent < Pom::Component
|
|
513
|
+
option :size, enums: [:sm, :md, :lg], default: :md
|
|
514
|
+
|
|
515
|
+
define_styles(:overlay, base: "fixed inset-0 bg-black bg-opacity-50")
|
|
516
|
+
|
|
517
|
+
define_styles(:dialog,
|
|
518
|
+
base: "bg-white rounded-lg shadow-xl",
|
|
519
|
+
size: {
|
|
520
|
+
sm: "max-w-sm",
|
|
521
|
+
md: "max-w-md",
|
|
522
|
+
lg: "max-w-lg"
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
define_styles(:header, base: "px-6 py-4 border-b")
|
|
527
|
+
define_styles(:body, base: "px-6 py-4")
|
|
528
|
+
define_styles(:footer, base: "px-6 py-4 border-t bg-gray-50")
|
|
529
|
+
|
|
530
|
+
def call
|
|
531
|
+
content_tag :div, class: styles_for(:overlay) do
|
|
532
|
+
content_tag :div, class: styles_for(:dialog, size: size) do
|
|
533
|
+
concat content_tag(:div, header_content, class: styles_for(:header))
|
|
534
|
+
concat content_tag(:div, body_content, class: styles_for(:body))
|
|
535
|
+
concat content_tag(:div, footer_content, class: styles_for(:footer))
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Access group styles:
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
styles_for(:group_name, option1: value1, option2: value2)
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### Tailwind Merge
|
|
549
|
+
|
|
550
|
+
Pom uses `tailwind_merge` to intelligently resolve conflicting Tailwind classes:
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
# Later classes override earlier ones for the same property
|
|
554
|
+
define_styles(
|
|
555
|
+
base: "p-4 bg-blue-500",
|
|
556
|
+
variant: {
|
|
557
|
+
danger: "p-6 bg-red-500" # p-6 overrides p-4, bg-red-500 overrides bg-blue-500
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
styles_for(variant: :danger)
|
|
562
|
+
# => "bg-red-500 p-6"
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
This ensures that:
|
|
566
|
+
|
|
567
|
+
- Only the most specific class is applied
|
|
568
|
+
- No duplicate or conflicting utilities
|
|
569
|
+
- Predictable style precedence
|
|
570
|
+
|
|
571
|
+
### Inheritance
|
|
572
|
+
|
|
573
|
+
Styles are inherited and merged:
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
class BaseButton < Pom::Component
|
|
577
|
+
option :size, enums: [:sm, :md, :lg], default: :md
|
|
578
|
+
|
|
579
|
+
define_styles(
|
|
580
|
+
base: "inline-flex items-center justify-center font-medium rounded",
|
|
581
|
+
size: {
|
|
582
|
+
sm: "px-3 py-1.5 text-sm",
|
|
583
|
+
md: "px-4 py-2 text-base",
|
|
584
|
+
lg: "px-6 py-3 text-lg"
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
class PrimaryButton < BaseButton
|
|
590
|
+
option :variant, enums: [:solid, :outline], default: :solid
|
|
591
|
+
|
|
592
|
+
define_styles(
|
|
593
|
+
base: "transition-colors duration-200", # Merged with parent base
|
|
594
|
+
variant: {
|
|
595
|
+
solid: "bg-blue-600 text-white hover:bg-blue-700",
|
|
596
|
+
outline: "border-2 border-blue-600 text-blue-600 hover:bg-blue-50"
|
|
597
|
+
},
|
|
598
|
+
size: {
|
|
599
|
+
lg: "px-8 py-4 text-xl" # Overrides parent's lg size
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Child styles:
|
|
606
|
+
|
|
607
|
+
- Merge with parent styles for the same keys
|
|
608
|
+
- Override parent values when there's a conflict
|
|
609
|
+
- Add new style keys and variants
|
|
610
|
+
|
|
611
|
+
## Helpers
|
|
612
|
+
|
|
613
|
+
### OptionHelper
|
|
614
|
+
|
|
615
|
+
Intelligently merge option hashes:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
def html_options
|
|
619
|
+
merge_options(
|
|
620
|
+
{ class: base_classes, data: { controller: "dropdown" } },
|
|
621
|
+
{ class: variant_classes, data: { action: "click->dropdown#toggle" } },
|
|
622
|
+
extra_options
|
|
623
|
+
)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Result:
|
|
627
|
+
# {
|
|
628
|
+
# class: "merged-classes", # Uses tailwind_merge
|
|
629
|
+
# data: {
|
|
630
|
+
# controller: "dropdown",
|
|
631
|
+
# action: "click->dropdown#toggle"
|
|
632
|
+
# }
|
|
633
|
+
# }
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
Special handling for:
|
|
637
|
+
|
|
638
|
+
- **`:class`**: Merged using `tailwind_merge`
|
|
639
|
+
- **`:data`**: Deep merged with concatenation for `controller` and `action`
|
|
640
|
+
- Other keys: Last value wins
|
|
641
|
+
|
|
642
|
+
### ViewHelper
|
|
643
|
+
|
|
644
|
+
Render Pom components using helper methods:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
# Instead of:
|
|
648
|
+
<%= render Pom::ButtonComponent.new(variant: :primary) { "Click" } %>
|
|
649
|
+
|
|
650
|
+
# Use:
|
|
651
|
+
<%= pom_button(variant: :primary) { "Click" } %>
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
The helper automatically converts `pom_component_name` to `Pom::ComponentNameComponent`.
|
|
655
|
+
|
|
656
|
+
**Important:** By default, this only works for components defined in the `Pom::` namespace:
|
|
657
|
+
|
|
658
|
+
```ruby
|
|
659
|
+
# pom_button looks for Pom::ButtonComponent
|
|
660
|
+
module Pom
|
|
661
|
+
class ButtonComponent < Pom::Component
|
|
662
|
+
# ...
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
To use helper methods with other namespaces (e.g., `ui_card`, `admin_dashboard`), see the [Configuration](#configuration) section to learn how to add custom prefixes.
|
|
668
|
+
|
|
669
|
+
If your components are NOT in a configured namespace, use the regular `render` helper:
|
|
670
|
+
|
|
671
|
+
```ruby
|
|
672
|
+
# For components outside configured namespaces
|
|
673
|
+
<%= render ButtonComponent.new(variant: :primary) { "Click" } %>
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### StimulusHelper
|
|
677
|
+
|
|
678
|
+
Generate Stimulus data attributes:
|
|
679
|
+
|
|
680
|
+
```ruby
|
|
681
|
+
class DropdownComponent < Pom::Component
|
|
682
|
+
def stimulus
|
|
683
|
+
"dropdown"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def button_options
|
|
687
|
+
merge_options(
|
|
688
|
+
stimulus_target(:button),
|
|
689
|
+
stimulus_action({ click: :toggle }),
|
|
690
|
+
{ class: "btn" }
|
|
691
|
+
)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def menu_options
|
|
695
|
+
merge_options(
|
|
696
|
+
stimulus_target(:menu),
|
|
697
|
+
stimulus_class(:open, "block"),
|
|
698
|
+
{ class: "dropdown-menu" }
|
|
699
|
+
)
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Available helpers:
|
|
705
|
+
|
|
706
|
+
#### `stimulus_target(name, stimulus: nil)`
|
|
707
|
+
|
|
708
|
+
```ruby
|
|
709
|
+
stimulus_target(:menu)
|
|
710
|
+
# => { "data-dropdown-target" => "menu" }
|
|
711
|
+
|
|
712
|
+
stimulus_target([:menu, :item])
|
|
713
|
+
# => { "data-dropdown-target" => "menu item" }
|
|
714
|
+
|
|
715
|
+
stimulus_target(:button, stimulus: "modal")
|
|
716
|
+
# => { "data-modal-target" => "button" }
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
#### `stimulus_action(action_map, stimulus: nil)`
|
|
720
|
+
|
|
721
|
+
```ruby
|
|
722
|
+
stimulus_action(:toggle)
|
|
723
|
+
# => { "data-action" => "dropdown#toggle" }
|
|
724
|
+
|
|
725
|
+
stimulus_action({ click: :toggle, mouseenter: :show })
|
|
726
|
+
# => { "data-action" => "click->dropdown#toggle mouseenter->dropdown#show" }
|
|
727
|
+
|
|
728
|
+
stimulus_action(:open, stimulus: "modal")
|
|
729
|
+
# => { "data-action" => "modal#open" }
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
#### `stimulus_value(name, value, stimulus: nil)`
|
|
733
|
+
|
|
734
|
+
```ruby
|
|
735
|
+
stimulus_value(:open, false)
|
|
736
|
+
# => { "data-dropdown-open-value" => false }
|
|
737
|
+
|
|
738
|
+
stimulus_value(:items, ["a", "b", "c"])
|
|
739
|
+
# => { "data-dropdown-items-value" => "[\"a\",\"b\",\"c\"]" }
|
|
740
|
+
|
|
741
|
+
stimulus_value(:count, 5, stimulus: "counter")
|
|
742
|
+
# => { "data-counter-count-value" => 5 }
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
#### `stimulus_class(name, value, stimulus: nil)`
|
|
746
|
+
|
|
747
|
+
```ruby
|
|
748
|
+
stimulus_class(:open, "block")
|
|
749
|
+
# => { "data-dropdown-open-class" => "block" }
|
|
750
|
+
|
|
751
|
+
stimulus_class(:hidden, "hidden", stimulus: "modal")
|
|
752
|
+
# => { "data-modal-hidden-class" => "hidden" }
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
#### `stimulus_controller`
|
|
756
|
+
|
|
757
|
+
Returns the dasherized controller name (requires a `stimulus` method):
|
|
758
|
+
|
|
759
|
+
```ruby
|
|
760
|
+
def stimulus
|
|
761
|
+
"dropdown"
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
stimulus_controller # => "dropdown"
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
## Component Utilities
|
|
768
|
+
|
|
769
|
+
### Component Name and ID
|
|
770
|
+
|
|
771
|
+
Auto-generated component identifiers:
|
|
772
|
+
|
|
773
|
+
```ruby
|
|
774
|
+
class UserCardComponent < Pom::Component
|
|
775
|
+
def call
|
|
776
|
+
content_tag :div, content, id: auto_id, data: { component: component_name }
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
component = UserCardComponent.new
|
|
781
|
+
component.component_name # => "user-card"
|
|
782
|
+
component.auto_id # => "user-card-a3f2"
|
|
783
|
+
component.uid # => "a3f2" (unique 4-char hex)
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
## Complete Example
|
|
787
|
+
|
|
788
|
+
Here's a comprehensive example combining all features:
|
|
789
|
+
|
|
790
|
+
```ruby
|
|
791
|
+
# app/components/pom/card_component.rb
|
|
792
|
+
module Pom
|
|
793
|
+
class CardComponent < Pom::Component
|
|
794
|
+
option :variant, enums: [:default, :bordered, :elevated], default: :default
|
|
795
|
+
option :padding, enums: [:none, :sm, :md, :lg], default: :md
|
|
796
|
+
option :clickable, default: false
|
|
797
|
+
option :href
|
|
798
|
+
|
|
799
|
+
define_styles(:container,
|
|
800
|
+
base: "bg-white rounded-lg overflow-hidden",
|
|
801
|
+
variant: {
|
|
802
|
+
default: "border border-gray-200",
|
|
803
|
+
bordered: "border-2 border-gray-900",
|
|
804
|
+
elevated: "shadow-lg"
|
|
805
|
+
},
|
|
806
|
+
clickable: {
|
|
807
|
+
true: "cursor-pointer transition hover:shadow-xl",
|
|
808
|
+
false: ""
|
|
809
|
+
}
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
define_styles(:body,
|
|
813
|
+
padding: {
|
|
814
|
+
none: "",
|
|
815
|
+
sm: "p-3",
|
|
816
|
+
md: "p-6",
|
|
817
|
+
lg: "p-8"
|
|
818
|
+
}
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
def call
|
|
822
|
+
if href.present?
|
|
823
|
+
link_to href, **container_options do
|
|
824
|
+
content_tag :div, content, class: styles_for(:body, padding: padding)
|
|
825
|
+
end
|
|
826
|
+
else
|
|
827
|
+
content_tag :div, **container_options do
|
|
828
|
+
content_tag :div, content, class: styles_for(:body, padding: padding)
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
private
|
|
834
|
+
|
|
835
|
+
def container_options
|
|
836
|
+
merge_options(
|
|
837
|
+
{
|
|
838
|
+
class: styles_for(:container, variant: variant, clickable: clickable || href?),
|
|
839
|
+
id: auto_id
|
|
840
|
+
},
|
|
841
|
+
extra_options
|
|
842
|
+
)
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
Usage:
|
|
849
|
+
|
|
850
|
+
```erb
|
|
851
|
+
<%= render Pom::CardComponent.new(variant: :elevated, padding: :lg, data: { controller: "card" }) do %>
|
|
852
|
+
<h3 class="text-xl font-bold mb-2">Card Title</h3>
|
|
853
|
+
<p class="text-gray-600">Card content goes here.</p>
|
|
854
|
+
<% end %>
|
|
855
|
+
|
|
856
|
+
<%# Or with the helper %>
|
|
857
|
+
<%= pom_card(variant: :bordered, clickable: true, href: "/details") do %>
|
|
858
|
+
<p>Clickable card that links to details page</p>
|
|
859
|
+
<% end %>
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
## Testing
|
|
863
|
+
|
|
864
|
+
Pom components work seamlessly with ViewComponent's testing utilities:
|
|
865
|
+
|
|
866
|
+
```ruby
|
|
867
|
+
# test/components/pom/button_component_test.rb
|
|
868
|
+
require "test_helper"
|
|
869
|
+
|
|
870
|
+
module Pom
|
|
871
|
+
class ButtonComponentTest < ViewComponent::TestCase
|
|
872
|
+
test "renders with default options" do
|
|
873
|
+
render_inline(ButtonComponent.new) { "Click me" }
|
|
874
|
+
|
|
875
|
+
assert_selector "button.inline-flex.bg-blue-600"
|
|
876
|
+
assert_text "Click me"
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
test "renders disabled button" do
|
|
880
|
+
render_inline(ButtonComponent.new(disabled: true)) { "Disabled" }
|
|
881
|
+
|
|
882
|
+
assert_selector "button.opacity-50.cursor-not-allowed"
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
test "validates enum values" do
|
|
886
|
+
assert_raises(ArgumentError) do
|
|
887
|
+
ButtonComponent.new(variant: :invalid)
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
test "captures extra options" do
|
|
892
|
+
render_inline(ButtonComponent.new(data: { controller: "button" })) { "Click" }
|
|
893
|
+
|
|
894
|
+
assert_selector "button[data-controller='button']"
|
|
895
|
+
end
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
## Best Practices
|
|
901
|
+
|
|
902
|
+
### 1. Keep Styles Cohesive
|
|
903
|
+
|
|
904
|
+
Group related styles together and use meaningful variant names:
|
|
905
|
+
|
|
906
|
+
```ruby
|
|
907
|
+
define_styles(
|
|
908
|
+
base: "btn",
|
|
909
|
+
variant: {
|
|
910
|
+
primary: "bg-blue-600 text-white",
|
|
911
|
+
secondary: "bg-gray-600 text-white",
|
|
912
|
+
danger: "bg-red-600 text-white"
|
|
913
|
+
}
|
|
914
|
+
)
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
### 2. Use Required Options for Critical Data
|
|
918
|
+
|
|
919
|
+
```ruby
|
|
920
|
+
option :user, required: true
|
|
921
|
+
option :action, required: true
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
### 3. Provide Sensible Defaults
|
|
925
|
+
|
|
926
|
+
```ruby
|
|
927
|
+
option :size, enums: [:sm, :md, :lg], default: :md
|
|
928
|
+
option :variant, enums: [:default, :primary], default: :default
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
### 4. Leverage Extra Options
|
|
932
|
+
|
|
933
|
+
Don't define options for every HTML attribute:
|
|
934
|
+
|
|
935
|
+
```ruby
|
|
936
|
+
def call
|
|
937
|
+
content_tag :div, content, **merge_options(
|
|
938
|
+
{ class: styles_for(variant: variant) },
|
|
939
|
+
extra_options # Captures id, data, aria attributes, etc.
|
|
940
|
+
)
|
|
941
|
+
end
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
### 5. Use Style Groups for Complex Components
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
define_styles(:header, base: "...")
|
|
948
|
+
define_styles(:body, base: "...")
|
|
949
|
+
define_styles(:footer, base: "...")
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
### 6. Validate with Enums
|
|
953
|
+
|
|
954
|
+
Use enums to catch typos and invalid values early:
|
|
955
|
+
|
|
956
|
+
```ruby
|
|
957
|
+
option :status, enums: [:draft, :published, :archived]
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### 7. Organize Components in the Configured Namespace
|
|
961
|
+
|
|
962
|
+
To use the `pom_*` helper methods, define your components in the configured namespace:
|
|
963
|
+
|
|
964
|
+
```ruby
|
|
965
|
+
# app/components/pom/button_component.rb
|
|
966
|
+
module Pom
|
|
967
|
+
class ButtonComponent < Pom::Component
|
|
968
|
+
# ...
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
## Configuration
|
|
974
|
+
|
|
975
|
+
You can configure Pom to use custom component prefixes in addition to the default `pom` prefix. This allows you to organize components in multiple namespaces and use helper methods for all of them.
|
|
976
|
+
|
|
977
|
+
Create an initializer:
|
|
978
|
+
|
|
979
|
+
```ruby
|
|
980
|
+
# config/initializers/pom.rb
|
|
981
|
+
Pom.configure do |config|
|
|
982
|
+
# Append custom prefixes to the default ["pom"]
|
|
983
|
+
config.component_prefixes << "ui"
|
|
984
|
+
config.component_prefixes << "admin"
|
|
985
|
+
end
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
Now you can use helper methods for components in any configured namespace:
|
|
989
|
+
|
|
990
|
+
```ruby
|
|
991
|
+
# app/components/ui/card_component.rb
|
|
992
|
+
module Ui
|
|
993
|
+
class CardComponent < Pom::Component
|
|
994
|
+
# ...
|
|
995
|
+
end
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
# app/components/admin/dashboard_component.rb
|
|
999
|
+
module Admin
|
|
1000
|
+
class DashboardComponent < Pom::Component
|
|
1001
|
+
# ...
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
Use them in views:
|
|
1007
|
+
|
|
1008
|
+
```erb
|
|
1009
|
+
<%# Looks for Ui::CardComponent %>
|
|
1010
|
+
<%= ui_card(variant: :bordered) do %>
|
|
1011
|
+
Card content
|
|
1012
|
+
<% end %>
|
|
1013
|
+
|
|
1014
|
+
<%# Looks for Admin::DashboardComponent %>
|
|
1015
|
+
<%= admin_dashboard(user: current_user) %>
|
|
1016
|
+
|
|
1017
|
+
<%# Still works - looks for Pom::ButtonComponent %>
|
|
1018
|
+
<%= pom_button(variant: :primary) do %>
|
|
1019
|
+
Click me
|
|
1020
|
+
<% end %>
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### Default Configuration
|
|
1024
|
+
|
|
1025
|
+
By default, `component_prefixes` is set to `["pom"]`, which means only `pom_*` helper methods work out of the box.
|
|
1026
|
+
|
|
1027
|
+
## License
|
|
1028
|
+
|
|
1029
|
+
The gem is available as open source under the terms of the [MIT License](MIT-LICENSE).
|
|
1030
|
+
|
|
1031
|
+
## Contributing
|
|
1032
|
+
|
|
1033
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
1034
|
+
|
|
1035
|
+
## Credits
|
|
1036
|
+
|
|
1037
|
+
Created by [Hoang Nghiem](https://github.com/hoangnghiem) · Maintained by [Pom](https://github.com/pom-io)
|