vident-phlex 0.14.1 → 1.0.0.alpha1

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.
data/README.md CHANGED
@@ -1,798 +1,707 @@
1
1
  # Vident
2
2
 
3
- Vident is a collection of gems that provide a set of tools for building web applications with Ruby on Rails.
3
+ A powerful Ruby gem for building interactive, type-safe components in Rails applications with seamless [Stimulus.js](https://stimulus.hotwired.dev/) integration.
4
4
 
5
- ## Included Gems
5
+ Vident supports both [ViewComponent](https://viewcomponent.org/) and [Phlex](https://www.phlex.fun/) rendering engines while providing a consistent API for creating
6
+ reusable UI components powered by [Stimulus.js](https://stimulus.hotwired.dev/).
6
7
 
7
- The core gems:
8
+ ## Table of Contents
8
9
 
9
- - `vident`: The core Vident library
10
- - `vident-phlex`: Phlex integration for Vident
11
- - `vident-view_component`: ViewComponent integration for Vident
10
+ - [Introduction](#introduction)
11
+ - [Installation](#installation)
12
+ - [Quick Start](#quick-start)
13
+ - [Core Concepts](#core-concepts)
14
+ - [Component DSL](#component-dsl)
15
+ - [Stimulus Integration](#stimulus-integration)
16
+ - [Advanced Features](#advanced-features)
17
+ - [Testing](#testing)
18
+ - [Contributing](#contributing)
12
19
 
13
- Note that you can use both `Phlex` and `ViewComponent` in the same application if desired.
20
+ ## Introduction
14
21
 
15
- And then optional extra features:
22
+ Vident is a collection of gems that enhance Rails view components with:
16
23
 
17
- - `vident-tailwind`: Tailwind CSS integration for Vident
18
- - `vident-typed`: Type system for Vident components
19
- - `vident-typed-minitest`: Minitest integration for typed Vident components
20
- - `vident-typed-phlex`: Phlex integration for typed Vident components
21
- - `vident-typed-view_component`: ViewComponent integration for typed Vident
24
+ - **Type-safe properties** using the Literal gem
25
+ - **First-class [Stimulus.js](https://stimulus.hotwired.dev/) integration** for interactive behaviors
26
+ - **Support for both [ViewComponent](https://viewcomponent.org/) and [Phlex](https://www.phlex.fun/)** rendering engines
27
+ - **Intelligent CSS class management** with built-in Tailwind CSS merging
28
+ - **Component caching** for improved performance
29
+ - **Declarative DSL** for clean, maintainable component code
22
30
 
23
- ## Directory Structure
31
+ ### Why Vident?
24
32
 
25
- The repository is structured like this:
33
+ Stimulus.js is a powerful framework for adding interactivity to HTML, but managing the data attributes can be cumbersome,
34
+ and refactoring can be error-prone (as say controller names are repeated in many places).
26
35
 
27
- ```
28
- vident/
29
- ├── lib/ # All gem code
30
- │ ├── vident.rb # Core entry point
31
- │ ├── vident-phlex.rb # Gem entry points
32
- ├── test/ # All tests
33
- │ ├── vident/ # Core tests
34
- │ ├── vident-phlex/ # Tests for each gem
35
- │ └── ...
36
- ├── docs/ # Documentation
37
- ├── examples/ # Examples
38
- ├── vident.gemspec # Gemspec for core gem
39
- ├── vident-phlex.gemspec # Gemspecs for each gem
40
- └── ...
41
- ```
42
-
43
- ## Development
44
-
45
- ### Setting Up Development Environment
46
-
47
- ```bash
48
- # Clone the repository
49
- git clone https://github.com/stevegeek/vident.git
50
- cd vident
51
-
52
- # Install dependencies
53
- bundle install
54
- ```
55
-
56
- ### Running Tests
57
-
58
- To run tests for all gems:
59
-
60
- ```bash
61
- rake test
62
- ```
63
-
64
- To run tests for a specific gem:
65
-
66
- ```bash
67
- rake test:vident-phlex
68
- ```
69
-
70
- ### Building and Installing Gems
71
-
72
- To build all gems:
73
-
74
- ```bash
75
- rake build
76
- ```
77
-
78
- To install all gems locally:
79
-
80
- ```bash
81
- rake install
82
- ```
83
-
84
- ## Contributing
85
-
86
- 1. Fork the repository
87
- 2. Create your feature branch (`git checkout -b my-new-feature`)
88
- 3. Commit your changes (`git commit -am 'Add some feature'`)
89
- 4. Push to the branch (`git push origin my-new-feature`)
90
- 5. Create a new Pull Request
36
+ Vident simplifies this by providing a declarative DSL for defining Stimulus controllers, actions, targets, and values
37
+ directly within your component classes so you don't need to manually craft data attributes in your templates.
91
38
 
92
- ## License
93
-
94
- The gems are available as open source under the terms of the [MIT License](LICENSE.txt).
95
-
96
- ---
97
-
98
- # Component Documentation
99
-
100
- ---
39
+ Vident also ensures that your components are flexible: for example you can easily add to, or override configuration,
40
+ classes etc at the point of rendering.
101
41
 
102
- ## gem: vident-typed-view_component
42
+ Vident's goal is to make building UI components more maintainable, and remove some of the boilerplate code of Stimulus
43
+ without being over-bearing or including too much magic.
103
44
 
104
- # Vident::Typed::ViewComponent
45
+ ## Installation
105
46
 
106
- Adds typed attributes to Vident ViewComponent components.
47
+ Add the core gem and your preferred rendering engine integration to your Gemfile:
107
48
 
108
49
  ```ruby
109
- class ApplicationComponent < ::Vident::Typed::ViewComponent::Base
110
- end
111
- ```
112
-
113
- For more details see [vident](https://github.com/stevegeek/vident).
50
+ # Core gem (required)
51
+ gem "vident"
114
52
 
115
- ### Examples
116
-
117
- Before we dive into a specific example note that there are some components implemented in `test/dummy/app/components`.
53
+ # Choose your rendering engine (at least one required)
54
+ gem "vident-view_component" # For ViewComponent support
55
+ gem "vident-phlex" # For Phlex support
56
+ ```
118
57
 
119
- Try them out by starting Rails:
58
+ Then run:
120
59
 
121
60
  ```bash
122
- cd test/dummy
123
61
  bundle install
124
- rails assets:precompile
125
- rails s
126
62
  ```
127
63
 
128
- and visiting http://localhost:3000
129
-
130
-
131
- ### A Vident component example (without Stimulus)
132
-
133
- First is an example component that uses `Vident::Typed::ViewComponent::Base` but no Stimulus features.
64
+ ## Quick Start
134
65
 
135
- It is an avatar component that can either be displayed as an image or as initials.
136
-
137
- It supports numerous sizes and shapes and can optionally have a border. It also generates a cache key for use in fragment caching or etag generation.
66
+ Here's a simple example of a Vident component using ViewComponent:
138
67
 
139
68
  ```ruby
140
- class AvatarComponent < ::Vident::Typed::ViewComponent::Base
141
- include ::Vident::Tailwind
142
- include ::Vident::Caching
143
-
144
- no_stimulus_controller
145
- with_cache_key :attributes
146
-
147
- attribute :url, String, allow_nil: true, allow_blank: false
148
- attribute :initials, String, allow_blank: false
149
-
150
- attribute :shape, Symbol, in: %i[circle square], default: :circle
151
-
152
- attribute :border, :boolean, default: false
153
-
154
- attribute :size, Symbol, in: %i[tiny small normal medium large x_large xx_large], default: :normal
155
-
156
- private
157
-
158
- def default_html_options
159
- if image_avatar?
160
- { class: "inline-block object-contain", src: url, alt: t(".image") }
161
- else
162
- { class: "inline-flex items-center justify-center bg-gray-500" }
163
- end
69
+ # app/components/button_component.rb
70
+ class ButtonComponent < Vident::ViewComponent::Base
71
+ # Define typed properties
72
+ prop :text, String, default: "Click me"
73
+ prop :url, _Nilable(String)
74
+ prop :style, Symbol, in: [:primary, :secondary], default: :primary
75
+ prop :clicked_count, Integer, default: 0
76
+
77
+ # Configure Stimulus integration
78
+ stimulus do
79
+ actions [:click, :handle_click]
80
+ # Static values
81
+ values loading_duration: 1000
82
+ # Map the clicked_count prop as a Stimulus value
83
+ values_from_props :clicked_count
84
+ # Dynamic values using procs (evaluated in component context)
85
+ values item_count: -> { @items.count }
86
+ values api_url: -> { Rails.application.routes.url_helpers.api_items_path }
87
+ # Static and dynamic classes
88
+ classes loading: "opacity-50 cursor-wait"
89
+ classes size: -> { @items.count > 10 ? "large" : "small" }
164
90
  end
165
91
 
166
- def element_classes
167
- [size_classes, shape_class, border? ? "border" : ""]
92
+ def call
93
+ root_element do
94
+ @text
95
+ end
168
96
  end
169
97
 
170
- alias_method :image_avatar?, :url?
98
+ private
171
99
 
172
- def shape_class
173
- (shape == :circle) ? "rounded-full" : "rounded-md"
100
+ def root_element_attributes
101
+ {
102
+ element_tag: @url ? :a : :button,
103
+ html_options: { href: @url }.compact
104
+ }
174
105
  end
175
106
 
176
- def size_classes
177
- case size
178
- when :tiny
179
- "w-6 h-6"
180
- when :small
181
- "w-8 h-8"
182
- when :medium
183
- "w-12 h-12"
184
- when :large
185
- "w-14 h-14"
186
- when :x_large
187
- "sm:w-24 sm:h-24 w-16 h-16"
188
- when :xx_large
189
- "sm:w-32 sm:h-32 w-24 h-24"
190
- else
191
- "w-10 h-10"
192
- end
193
- end
194
-
195
- def text_size_class
196
- case size
197
- when :tiny
198
- "text-xs"
199
- when :small
200
- "text-xs"
201
- when :medium
202
- "text-lg"
203
- when :large
204
- "sm:text-xl text-lg"
205
- when :extra_large
206
- "sm:text-2xl text-xl"
207
- else
208
- "text-medium"
107
+ def element_classes
108
+ base_classes = "btn"
109
+ case @style
110
+ when :primary
111
+ "#{base_classes} btn-primary"
112
+ when :secondary
113
+ "#{base_classes} btn-secondary"
209
114
  end
210
115
  end
211
116
  end
212
117
  ```
213
118
 
214
- ```erb
215
- <%= render root(
216
- element_tag: image_avatar? ? :img : :div,
217
- html_options: default_html_options
218
- ) do %>
219
- <% unless image_avatar? %>
220
- <span class="<%= text_size_class %> font-medium leading-none text-white"><%= initials %></span>
221
- <% end %>
222
- <% end %>
223
119
 
224
- ```
120
+ Add the corresponding Stimulus controller would be:
225
121
 
226
- Example usages:
122
+ ```javascript
123
+ // app/javascript/controllers/button_component_controller.js
124
+ // Can also be "side-car" in the same directory as the component, see the documentation for details
125
+ import { Controller } from "@hotwired/stimulus"
227
126
 
228
- ```erb
229
- <!-- These will render -->
230
- <%= render AvatarComponent.new(url: "https://someurl.com/avatar.jpg", initials: "AB" size: :large) %>
231
- <%= render AvatarComponent.new(url: "https://someurl.com/avatar.jpg", html_options: {alt: "My alt text", class: "object-scale-down"}) %>
232
- <%= render AvatarComponent.new(initials: "SG", size: :small) %>
233
- <%= render AvatarComponent.new(initials: "SG", size: :large, html_options: {class: "border-2 border-red-600"}) %>
234
-
235
- <!-- These will raise an error -->
236
- <!-- missing initals -->
237
- <%= render AvatarComponent.new(url: "https://someurl.com/avatar.jpg", size: :large) %>
238
- <!-- initials blank -->
239
- <%= render AvatarComponent.new(initials: "", size: :large) %>
240
- <!-- invalid size -->
241
- <%= render AvatarComponent.new(initials: "SG", size: :foo_bar) %>
127
+ export default class extends Controller {
128
+ static values = {
129
+ clickedCount: Number,
130
+ loadingDuration: Number
131
+ }
132
+ static classes = ["loading"]
133
+
134
+ handleClick(event) {
135
+ // Increment counter
136
+ this.clickedCountValue++
137
+
138
+ // Add loading state
139
+ this.element.classList.add(this.loadingClass)
140
+ this.element.disabled = true
141
+
142
+ // Use the loading duration from the component
143
+ setTimeout(() => {
144
+ this.element.classList.remove(this.loadingClass)
145
+ this.element.disabled = false
146
+
147
+ // Update text to show count
148
+ this.element.textContent = `${this.element.textContent} (${this.clickedCountValue})`
149
+ }, this.loadingDurationValue)
150
+ }
151
+ }
242
152
  ```
243
153
 
154
+ Use the component in your views:
244
155
 
245
- The following is rendered when used `render AvatarComponent.new(initials: "SG", size: :small, border: true)`:
156
+ ```erb
157
+ <!-- Default clicked count of 0 -->
158
+ <%= render ButtonComponent.new(text: "Save", style: :primary) %>
246
159
 
247
- ```html
248
- <div class="avatar-component w-8 h-8 rounded-full border inline-flex items-center justify-center bg-gray-500" id="avatar-component-9790427-12">
249
- <span class="text-xs font-medium leading-none text-white">SG</span>
250
- </div>
251
- ```
160
+ <!-- Pre-set clicked count -->
161
+ <%= render ButtonComponent.new(text: "Submit", style: :primary, clicked_count: 5) %>
252
162
 
253
- The following is rendered when used `render AvatarComponent.new(url: "https://i.pravatar.cc/300", initials: "AB", html_options: {alt: "My alt text", class: "block"})`:
163
+ <!-- Link variant -->
164
+ <%= render ButtonComponent.new(text: "Cancel", url: "/home", style: :secondary) %>
254
165
 
255
- ```html
256
- <img src="https://i.pravatar.cc/300" alt="My alt text" class="avatar-component w-10 h-10 rounded-full object-contain block" id="avatar-component-7083941-11">
166
+ <!-- Override things -->
167
+ <%= render ButtonComponent.new(text: "Cancel", url: "/home" classes: "bg-red-900", html_options: {role: "button"}) %>
257
168
  ```
258
169
 
259
- ----
260
-
261
- ![Example](examples/avatar.png)
170
+ The rendered HTML includes all Stimulus data attributes:
262
171
 
172
+ ```html
173
+ <!-- First button with default count -->
174
+ <button class="bg-blue-500 hover:bg-blue-700 text-white"
175
+ data-controller="button-component"
176
+ data-action="click->button-component#handleClick"
177
+ data-button-component-clicked-count-value="0"
178
+ data-button-component-loading-duration-value="1000"
179
+ data-button-component-loading-class="opacity-50 cursor-wait"
180
+ id="button-component-123">
181
+ Save
182
+ </button>
183
+
184
+ <!-- Second button with pre-set count -->
185
+ <button class="bg-blue-500 hover:bg-blue-700 text-white"
186
+ data-controller="button-component"
187
+ data-action="click->button-component#handleClick"
188
+ data-button-component-clicked-count-value="5"
189
+ data-button-component-loading-duration-value="1000"
190
+ data-button-component-loading-class="opacity-50 cursor-wait"
191
+ id="button-component-456">
192
+ Submit
193
+ </button>
194
+ ```
195
+
196
+ ## Core Concepts
197
+
198
+ ### Component Properties
199
+
200
+ Vident uses the Literal gem to provide type-safe component properties:
263
201
 
264
- ### Another ViewComponent + Vident example with Stimulus
265
-
266
- Consider the following ERB that might be part of an application's views. The app uses `ViewComponent`, `Stimulus` and `Vident`.
267
-
268
- The Greeter is a component that displays a text input and a button. When the button is clicked, the text input's value is
269
- used to greet the user. At the same time the button changes to be a 'reset' button, which resets the greeting when clicked again.
270
-
271
- ![ex1.gif](examples/ex1.gif)
202
+ ```ruby
203
+ class CardComponent < Vident::ViewComponent::Base
204
+ # Basic property with type
205
+ prop :title, String
206
+
207
+ # Property with default value
208
+ prop :subtitle, String, default: ""
209
+
210
+ # Nullable property
211
+ prop :image_url, _Nilable(String)
212
+
213
+ # Property with validation
214
+ prop :size, _Union(:small, :medium, :large), default: :medium
215
+
216
+ # Boolean property (creates predicate method)
217
+ prop :featured, _Boolean, default: false
218
+ end
219
+ ```
272
220
 
273
- ```erb
274
- <%# app/views/home/index.html.erb %>
275
-
276
- <!-- ... -->
277
-
278
- <!-- render the Greeter ViewComponent (that uses Vident) -->
279
- <%= render ::GreeterComponent.new(cta: "Hey!", html_options: {class: "my-4"}) do |greeter| %>
280
- <%# this component has a slot called `trigger` that renders a `ButtonComponent` (which also uses Vident) %>
281
- <% greeter.with_trigger(
282
-
283
- # The button component has attributes that are typed
284
- before_clicked: "Greet",
285
- after_clicked: "Greeted! Reset?",
286
-
287
- # A stimulus action is added to the button that triggers the `greet` action on the greeter stimulus controller.
288
- # This action will be added to any defined on the button component itself
289
- actions: [
290
- greeter.action(:click, :greet),
291
- ],
292
-
293
- # We can also override the default button classes of our component, or set other HTML attributes
294
- html_options: {
295
- class: "bg-red-500 hover:bg-red-700"
296
- }
297
- ) %>
298
- <% end %>
221
+ ### Built-in Properties
299
222
 
300
- <!-- ... -->
301
- ```
223
+ Every Vident component includes these properties:
302
224
 
303
- The output HTML of the above, using Vident, is:
304
-
305
- ```html
306
- <div class="greeter-component py-2 my-4"
307
- data-controller="greeter-component"
308
- data-greeter-component-pre-click-class="text-md text-gray-500"
309
- data-greeter-component-post-click-class="text-xl text-blue-700"
310
- id="greeter-component-1599855-6">
311
- <input type="text"
312
- data-greeter-component-target="name"
313
- class="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
314
- <button class="button-component ml-4 whitespace-no-wrap bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded bg-red-500 hover:bg-red-700"
315
- data-controller="button-component"
316
- data-action="click->greeter-component#greet button-component#changeMessage"
317
- data-button-component-after-clicked-message="Greeted! Reset?"
318
- data-button-component-before-clicked-message="Greet"
319
- id="button-component-7799479-7">Hey!</button>
320
- <!-- you can also use the `target_tag` helper to render targets -->
321
- <span class="ml-4 text-md text-gray-500"
322
- data-greeter-component-target="output">
323
- ...
324
- </span>
325
- </div>
326
- ```
225
+ - `element_tag` - The HTML tag for the root element (default: `:div`)
226
+ - `id` - The component's DOM ID (auto-generated if not provided)
227
+ - `classes` - Additional CSS classes
228
+ - `html_options` - Hash of HTML attributes
327
229
 
328
- Let's look at the components in more detail.
230
+ ### Root Element Rendering
329
231
 
330
- The main component is the `GreeterComponent`:
232
+ The `root_element` helper method renders your component's root element with all configured attributes:
331
233
 
332
234
  ```ruby
333
- # app/components/greeter_component.rb
235
+ # In your component class
236
+ def element_classes
237
+ ["card", featured? ? "card-featured" : nil]
238
+ end
334
239
 
335
- class GreeterComponent < ::Vident::ViewComponent::Base
336
- renders_one :trigger, ButtonComponent
240
+ private
241
+
242
+ def root_element_attributes
243
+ {
244
+ html_options: { role: "article", "aria-label": title }
245
+ }
337
246
  end
338
247
  ```
339
248
 
340
249
  ```erb
341
- <%# app/components/greeter_component.html.erb %>
342
-
343
- <%# Rendering the `root` element creates a tag which has stimulus `data-*`s, a unique id & other attributes set. %>
344
- <%# The stimulus controller name (identifier) is derived from the component name, and then used to generate the relavent data attribute names. %>
345
-
346
- <%= render root named_classes: {
347
- pre_click: "text-md text-gray-500", # named classes are exposed to Stimulus as `data-<controller>-<n>-class` attributes
348
- post_click: "text-xl text-blue-700",
349
- html_options: {class: "py-2"}
350
- } do |greeter| %>
351
-
352
- <%# `greeter` is the root element and exposes methods to generate stimulus targets and actions %>
353
- <input type="text"
354
- <%= greeter.as_target(:name) %>
355
- class="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
356
-
357
- <%# Render the slot %>
358
- <%= trigger %>
359
-
360
- <%# you can also use the `target_tag` helper to render targets %>
361
- <%= greeter.target_tag(
362
- :span,
363
- :output,
364
- # Stimulus named classes can be referenced to set class attributes at render time
365
- class: "ml-4 #{greeter.named_classes(:pre_click)}"
366
- ) do %>
367
- ...
368
- <% end %>
250
+ <%# In your template %>
251
+ <%= root_element do %>
252
+ <h2><%= title %></h2>
253
+ <p><%= subtitle %></p>
369
254
  <% end %>
370
-
371
255
  ```
372
256
 
373
- ```js
374
- // app/components/greeter_component_controller.js
257
+ ## Component DSL
375
258
 
376
- import { Controller } from "@hotwired/stimulus"
259
+ ### ViewComponent Integration
377
260
 
378
- // This is a Stimulus controller that is automatically registered for the `GreeterComponent`
379
- // and is 'sidecar' to the component. You can see that while in the ERB we use Ruby naming conventions
380
- // with snake_case Symbols, here they are converted to camelCase names. We can also just use camelCase
381
- // in the ERB if we want.
382
- export default class extends Controller {
383
- static targets = [ "name", "output" ]
384
- static classes = [ "preClick", "postClick" ]
385
-
386
- greet() {
387
- this.clicked = !this.clicked;
388
- this.outputTarget.classList.toggle(this.preClickClasses, !this.clicked);
389
- this.outputTarget.classList.toggle(this.postClickClasses, this.clicked);
390
-
391
- if (this.clicked)
392
- this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`
393
- else
394
- this.clear();
395
- }
261
+ ```ruby
262
+ class MyComponent < Vident::ViewComponent::Base
263
+ # Component code
264
+ end
396
265
 
397
- clear() {
398
- this.outputTarget.textContent = '...';
399
- this.nameTarget.value = '';
400
- }
401
- }
266
+ # Or with an application base class
267
+ class ApplicationComponent < Vident::ViewComponent::Base
268
+ # Shared configuration
269
+ end
270
+
271
+ class MyComponent < ApplicationComponent
272
+ # Component code
273
+ end
402
274
  ```
403
275
 
404
- The slot renders a `ButtonComponent` component:
276
+ ### Phlex Integration
405
277
 
406
278
  ```ruby
407
- # app/components/button_component.rb
408
-
409
- class ButtonComponent < ::Vident::Typed::ViewComponent::Base
410
- # The attributes can specify an expected type, a default value and if nil is allowed.
411
- attribute :after_clicked, String, default: "Greeted!"
412
- attribute :before_clicked, String, allow_nil: false
413
-
414
- # This example is a templateless ViewComponent.
415
- def call
416
- # The button is rendered as a <button> tag with an click action on its own controller.
417
- render root(
418
- element_tag: :button,
419
-
420
- # We can define actions as arrays of Symbols, or pass manually manually crafted strings.
421
- # Here we specify the action name only, implying an action on the current components controller
422
- # and the default event type of `click`.
423
- actions: [:change_message],
424
- # Alternatively: [:click, :change_message] or ["click", "changeMessage"] or even "click->button-component#changeMessage"
425
-
426
- # A couple of data values are also set which will be available to the controller
427
- data_maps: [{after_clicked_message: after_clicked, before_clicked_message: before_clicked}],
428
-
429
- # The <button> tag has a default styling set directly on it. Note that
430
- # if not using utility classes, you can style the component using its
431
- # canonical class name (which is equal to the component's stimulus identifier),
432
- # in this case `button-component`.
433
- html_options: {class: "ml-4 whitespace-no-wrap bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"}
434
- ) do
435
- @before_clicked
279
+ class MyComponent < Vident::Phlex::HTML
280
+ def view_template
281
+ root do
282
+ h1 { "Hello from Phlex!" }
436
283
  end
437
284
  end
438
285
  end
439
286
  ```
440
287
 
441
- ```js
442
- // app/components/button_component_controller.js
443
-
444
- import { Controller } from "@hotwired/stimulus"
445
-
446
- export default class extends Controller {
447
- // The action is in camelCase.
448
- changeMessage() {
449
- this.clicked = !this.clicked;
450
- // The data attributes have their naming convention converted to camelCase.
451
- this.element.textContent = this.clicked ? this.data.get("afterClickedMessage") : this.data.get("beforeClickedMessage");
452
- }
453
- }
288
+ ## Stimulus Integration
454
289
 
455
- ```
290
+ Vident provides comprehensive Stimulus.js integration to add interactivity to your components.
456
291
 
457
- ### Usage
458
- How to use my plugin.
292
+ ### Declarative Stimulus DSL
459
293
 
460
- ### Installation
461
- Add this line to your application's Gemfile:
294
+ Use the `stimulus` block for clean, declarative configuration:
462
295
 
463
296
  ```ruby
464
- gem "vident-typed-view_component"
465
- ```
466
-
467
- And then execute:
468
- ```bash
469
- $ bundle
470
- ```
471
-
472
- Or install it yourself as:
473
- ```bash
474
- $ gem install vident-typed-view_component
297
+ class ToggleComponent < Vident::ViewComponent::Base
298
+ prop :expanded, _Boolean, default: false
299
+
300
+ stimulus do
301
+ # Define actions the controller responds to
302
+ actions :toggle, :expand, :collapse
303
+
304
+ # Define targets for DOM element references
305
+ targets :button, :content
306
+
307
+ # Define static values
308
+ values animation_duration: 300
309
+
310
+ # Define dynamic values using procs (evaluated in component context)
311
+ values item_count: -> { @items.count }
312
+ values current_state: proc { expanded? ? "open" : "closed" }
313
+
314
+ # Map values from component props
315
+ values_from_props :expanded
316
+
317
+ # Define CSS classes for different states
318
+ classes expanded: "block",
319
+ collapsed: "hidden",
320
+ transitioning: "opacity-50"
321
+ end
322
+ end
475
323
  ```
476
324
 
477
- ---
325
+ ### Dynamic Values and Classes with Procs
478
326
 
327
+ The Stimulus DSL supports dynamic values and classes using procs or lambdas that are evaluated in the component instance context:
479
328
 
480
- ## gem: vident-view_component
329
+ ```ruby
330
+ class DynamicComponent < Vident::ViewComponent::Base
331
+ prop :items, _Array(Hash), default: -> { [] }
332
+ prop :loading, _Boolean, default: false
333
+ prop :user, _Nilable(User)
334
+
335
+ stimulus do
336
+ # Mix static and dynamic values in a single call
337
+ values(
338
+ static_config: "always_same",
339
+ item_count: -> { @items.count },
340
+ loading_state: proc { @loading ? "loading" : "idle" },
341
+ user_role: -> { @user&.role || "guest" },
342
+ api_endpoint: -> { Rails.application.routes.url_helpers.api_items_path }
343
+ )
344
+
345
+ # Mix static and dynamic classes
346
+ classes(
347
+ base: "component-container",
348
+ loading: -> { @loading ? "opacity-50 cursor-wait" : "" },
349
+ size: proc { @items.count > 10 ? "large" : "small" },
350
+ theme: -> { current_user&.dark_mode? ? "dark" : "light" }
351
+ )
352
+
353
+ # Dynamic actions and targets
354
+ actions -> { @loading ? [] : [:click, :submit] }
355
+ targets -> { @expanded ? [:content, :toggle] : [:toggle] }
356
+ end
357
+
358
+ private
359
+
360
+ def current_user
361
+ @current_user ||= User.current
362
+ end
363
+ end
364
+ ```
481
365
 
482
- # Vident::ViewComponent
366
+ Procs have access to instance variables, component methods, and Rails helpers.
483
367
 
484
- [ViewComponent](https://viewcomponent.org/) powered [Vident](https://github.com/stevegeek/vident) components.
368
+ **Important**: Each proc returns a single value for its corresponding stimulus attribute. If a proc returns an array, that entire array is treated as a single value, not multiple separate values. To provide multiple values for an attribute, use multiple procs or mix procs with static values:
485
369
 
486
370
  ```ruby
487
- class ApplicationComponent < ::Vident::ViewComponent::Base
371
+ stimulus do
372
+ # Single proc returns a single value (even if it's an array)
373
+ actions -> { @expanded ? [:click, :submit] : :click }
374
+
375
+ # Multiple procs provide multiple values
376
+ actions -> { @can_edit ? :edit : nil },
377
+ -> { @can_delete ? :delete : nil },
378
+ :cancel # static value
379
+
380
+ # This results in: [:edit, :delete, :cancel] (assuming both conditions are true)
488
381
  end
489
382
  ```
490
383
 
491
- For more details see [vident](https://github.com/stevegeek/vident).
492
-
493
- ### Examples
384
+ ### Scoped Custom Events
494
385
 
495
- Before we dive into a specific example note that there are some components implemented in the `test/dummy/app/components`.
386
+ Vident provides helper methods to generate scoped event names for dispatching custom events that are unique to your component:
496
387
 
497
- Try them out by starting Rails:
388
+ ```ruby
389
+ class MyComponent < Vident::ViewComponent::Base
390
+ stimulus do
391
+ # Define an action that responds to a scoped event
392
+ actions [stimulus_scoped_event_on_window(:data_loaded), :handle_data_loaded]
393
+ end
394
+
395
+ def handle_click
396
+ # Dispatch a scoped event from JavaScript
397
+ # This would generate: "my-component:dataLoaded"
398
+ puts stimulus_scoped_event(:data_loaded)
399
+
400
+ # For window events, this generates: "my-component:dataLoaded@window"
401
+ puts stimulus_scoped_event_on_window(:data_loaded)
402
+ end
403
+ end
498
404
 
499
- ```bash
500
- cd test/dummy
501
- bundle install
502
- rails assets:precompile
503
- rails s
405
+ # Available as both class and instance methods:
406
+ MyComponent.stimulus_scoped_event(:data_loaded) # => "my-component:dataLoaded"
407
+ MyComponent.new.stimulus_scoped_event(:data_loaded) # => "my-component:dataLoaded"
504
408
  ```
505
409
 
506
- and visiting http://localhost:3000
507
-
508
-
509
- ### A Vident component example (without Stimulus)
410
+ This is useful for:
411
+ - Dispatching events from Stimulus controllers to communicate between components
412
+ - Creating unique event names that won't conflict with other components
413
+ - Setting up window-level event listeners with scoped names
510
414
 
511
- First is an example component that uses `Vident::ViewComponent::Base` but no Stimulus features.
415
+ ### Manual Stimulus Configuration
512
416
 
513
- It is an avatar component that can either be displayed as an image or as initials. It supports numerous sizes and shapes and can optionally have a border. It also generates a cache key for use in fragment caching or etag generation.
417
+ For more control, configure Stimulus attributes manually:
514
418
 
515
419
  ```ruby
516
- class AvatarComponent < ::Vident::ViewComponent::Base
517
- include ::Vident::Tailwind
518
- include ::Vident::Caching
519
-
520
- no_stimulus_controller
521
- with_cache_key :attributes
420
+ class CustomComponent < Vident::ViewComponent::Base
421
+ private
422
+
423
+ def root_element_attributes
424
+ {
425
+ element_tag: :article,
426
+ stimulus_controllers: ["custom", "analytics"],
427
+ stimulus_actions: [
428
+ [:click, :handleClick],
429
+ [:custom_event, :handleCustom]
430
+ ],
431
+ stimulus_values: {
432
+ endpoint: "/api/data",
433
+ refresh_interval: 5000
434
+ },
435
+ stimulus_targets: {
436
+ container: true
437
+ }
438
+ }
439
+ end
440
+ end
441
+ ```
522
442
 
523
- attribute :url, allow_nil: true
524
- attribute :initials, allow_nil: false
443
+ or you can use tag helpers to generate HTML with Stimulus attributes:
525
444
 
526
- attribute :shape, default: :circle
445
+ ```erb
446
+ <%= content_tag(:input, type: "text", class: "...", data: {**greeter.stimulus_target(:name)}) %>
447
+ <%= content_tag(:button, @cta, class: "...", data: {**greeter.stimulus_action([:click, :greet])}) do %>
448
+ <%= @cta %>
449
+ <% end %>
450
+ <%= content_tag(:span, class: "...", data: {**greeter.stimulus_target(:output)}) %>
527
451
 
528
- attribute :border, default: false
452
+ <%# OR use the vident tag helper %>
529
453
 
530
- attribute :size, default: :normal
454
+ <%= greeter.tag(:input, stimulus_target: :name, type: "text", class: "...") %>
455
+ <%= greeter.tag(:button, stimulus_action: [:click, :greet], class: "...") do %>
456
+ <%= @cta %>
457
+ <% end %>
458
+ <%= greeter.tag(:span, stimulus_target: :output, class: "...") %>
459
+ ```
531
460
 
532
- private
461
+ or in your Phlex templates:
533
462
 
534
- def default_html_options
535
- if image_avatar?
536
- { class: "inline-block object-contain", src: url, alt: t(".image") }
537
- else
538
- { class: "inline-flex items-center justify-center bg-gray-500" }
539
- end
463
+ ```ruby
464
+ root_element do |greeter|
465
+ input(type: "text", data: {**greeter.stimulus_target(:name)}, class: %(...))
466
+ trigger_or_default(greeter)
467
+ greeter.tag(:span, stimulus_target: :output, class: "ml-4 #{greeter.class_list_for_stimulus_classes(:pre_click)}") do
468
+ plain %( ... )
540
469
  end
470
+ end
471
+ ```
541
472
 
542
- def element_classes
543
- [size_classes, shape_class, border? ? "border" : ""]
544
- end
473
+ or directly in the ViewComponent template (eg with ERB) using the `as_stimulus_*` helpers
545
474
 
546
- alias_method :image_avatar?, :url?
475
+ ```erb
476
+ <%# HTML embellishment approach, most familiar to working with HTML in ERB, but is injecting directly into open HTML tags... %>
477
+ <input type="text"
478
+ <%= greeter.as_stimulus_targets(:name) %>
479
+ class="...">
480
+ <button <%= greeter.as_stimulus_actions([:click, :greet]) %>
481
+ class="...">
482
+ <%= @cta %>
483
+ </button>
484
+ <span <%= greeter.as_stimulus_targets(:output) %> class="..."></span>
485
+ ```
547
486
 
548
- def shape_class
549
- (shape == :circle) ? "rounded-full" : "rounded-md"
550
- end
551
487
 
552
- def size_classes
553
- case size
554
- when :tiny
555
- "w-6 h-6"
556
- when :small
557
- "w-8 h-8"
558
- when :medium
559
- "w-12 h-12"
560
- when :large
561
- "w-14 h-14"
562
- when :x_large
563
- "sm:w-24 sm:h-24 w-16 h-16"
564
- when :xx_large
565
- "sm:w-32 sm:h-32 w-24 h-24"
566
- else
567
- "w-10 h-10"
568
- end
569
- end
488
+ ### Stimulus Helpers in Templates
570
489
 
571
- def text_size_class
572
- case size
573
- when :tiny
574
- "text-xs"
575
- when :small
576
- "text-xs"
577
- when :medium
578
- "text-lg"
579
- when :large
580
- "sm:text-xl text-lg"
581
- when :extra_large
582
- "sm:text-2xl text-xl"
583
- else
584
- "text-medium"
585
- end
586
- end
587
- end
588
- ```
490
+ Vident provides helper methods for generating Stimulus attributes:
589
491
 
590
492
  ```erb
591
- <%= render root(
592
- element_tag: image_avatar? ? :img : :div,
593
- html_options: default_html_options
594
- ) do %>
595
- <% unless image_avatar? %>
596
- <span class="<%= text_size_class %> font-medium leading-none text-white"><%= initials %></span>
493
+ <%= render root do |component| %>
494
+ <!-- Create a target -->
495
+ <div <%= component.as_target(:content) %>>
496
+ Content here
497
+ </div>
498
+
499
+ <!-- Create an action -->
500
+ <button <%= component.as_action(:click, :toggle) %>>
501
+ Toggle
502
+ </button>
503
+
504
+ <!-- Use the tag helper -->
505
+ <%= component.tag :div, stimulus_target: :output, class: "mt-4" do %>
506
+ Output here
597
507
  <% end %>
508
+
509
+ <!-- Multiple targets/actions -->
510
+ <input <%= component.as_targets(:input, :field) %>
511
+ <%= component.as_actions([:input, :validate], [:change, :save]) %>>
598
512
  <% end %>
599
513
  ```
600
514
 
601
- Example usages:
515
+ ### Stimulus Outlets
602
516
 
603
- ```erb
604
- <%= render AvatarComponent.new(url: "https://someurl.com/avatar.jpg", initials: "AB" size: :large) %>
605
- <%= render AvatarComponent.new(url: "https://someurl.com/avatar.jpg", html_options: {alt: "My alt text", class: "object-scale-down"}) %>
606
- <%= render AvatarComponent.new(initials: "SG", size: :small) %>
607
- <%= render AvatarComponent.new(initials: "SG", size: :large, html_options: {class: "border-2 border-red-600"}) %>
608
- ```
517
+ Connect components via Stimulus outlets:
609
518
 
610
- The following is rendered when used `render AvatarComponent.new(initials: "SG", size: :small, border: true)`:
611
519
 
612
- ```html
613
- <div class="avatar-component w-8 h-8 rounded-full border inline-flex items-center justify-center bg-gray-500" id="avatar-component-9790427-12">
614
- <span class="text-xs font-medium leading-none text-white">SG</span>
615
- </div>
616
- ```
617
520
 
618
- The following is rendered when used `render AvatarComponent.new(url: "https://i.pravatar.cc/300", initials: "AB", html_options: {alt: "My alt text", class: "block"})`:
619
521
 
620
- ```html
621
- <img src="https://i.pravatar.cc/300" alt="My alt text" class="avatar-component w-10 h-10 rounded-full object-contain block" id="avatar-component-7083941-11">
622
- ```
522
+ ### Stimulus Controller Naming
623
523
 
624
- ----
524
+ Vident automatically generates Stimulus controller names based on your component class:
625
525
 
626
- ![Example](examples/avatar.png)
526
+ - `ButtonComponent` → `button-component`
527
+ - `Admin::UserCardComponent` → `admin--user-card-component`
528
+ - `MyApp::WidgetComponent` → `my-app--widget-component`
627
529
 
628
- ### Usage
629
- How to use my plugin.
530
+ ### Working with Child Components
630
531
 
631
- ### Installation
632
- Add this line to your application's Gemfile:
532
+ Setting Stimulus configuration between parent and child components:
633
533
 
634
534
  ```ruby
635
- gem "vident-view_component"
535
+ class ParentComponent < Vident::ViewComponent::Base
536
+ renders_one :a_nested_component, ButtonComponent
537
+
538
+ stimulus do
539
+ actions :handleTrigger
540
+ end
541
+ end
636
542
  ```
637
543
 
638
- And then execute:
639
- ```bash
640
- $ bundle
544
+ ```erb
545
+ <%= root_element do |parent| %>
546
+ <% parent.with_a_nested_component(
547
+ text: "Click me",
548
+ stimulus_actions: [
549
+ parent.stimulus_action(:click, :handleTrigger)
550
+ ]
551
+ ) %>
552
+ <% end %>
641
553
  ```
642
554
 
643
- Or install it yourself as:
644
- ```bash
645
- $ gem install vident-view_component
646
- ```
555
+ This creates a nested component that once clicked triggers the parent components `handleTrigger` action.
647
556
 
648
- ## gem: vident-phlex
557
+ ## Other Features
649
558
 
650
- # Vident::Phlex
559
+ ### Custom Element Tags
651
560
 
652
- [Phlex](https://phlex.fun/) powered [Vident](https://github.com/stevegeek/vident) components.
561
+ Change the root element tag dynamically:
653
562
 
654
563
  ```ruby
655
- class ApplicationComponent < ::Vident::Phlex::HTML
564
+ class LinkOrButtonComponent < Vident::ViewComponent::Base
565
+ prop :url, _Nilable(String)
566
+
567
+ private
568
+
569
+ def root_element_attributes
570
+ {
571
+ element_tag: url? ? :a : :button,
572
+ html_options: {
573
+ href: url,
574
+ type: url? ? nil : "button"
575
+ }.compact
576
+ }
577
+ end
656
578
  end
657
579
  ```
658
580
 
659
- For more details see [vident](https://github.com/stevegeek/vident).
581
+ ### Intelligent Class Management
660
582
 
661
- ### Usage
662
- How to use my plugin.
663
-
664
- ### Installation
665
- Add this line to your application's Gemfile:
583
+ Vident intelligently merges CSS classes from multiple sources:
666
584
 
667
585
  ```ruby
668
- gem "vident-phlex"
669
- ```
670
-
671
- And then execute:
672
- ```bash
673
- $ bundle
586
+ class StyledComponent < Vident::ViewComponent::Base
587
+ prop :variant, Symbol, default: :default
588
+
589
+ private
590
+
591
+ # Classes on the root element
592
+ def element_classes
593
+ ["base-class", variant_class]
594
+ end
595
+
596
+ def variant_class
597
+ case @variant
598
+ when :primary then "text-blue-600 bg-blue-100"
599
+ when :danger then "text-red-600 bg-red-100"
600
+ else "text-gray-600 bg-gray-100"
601
+ end
602
+ end
603
+ end
674
604
  ```
675
605
 
676
- Or install it yourself as:
677
- ```bash
678
- $ gem install vident-phlex
606
+ Usage:
607
+ ```erb
608
+ <!-- All classes are intelligently merged -->
609
+ <%= render StyledComponent.new(
610
+ variant: :primary,
611
+ classes: "rounded-lg shadow"
612
+ ) %>
613
+ <!-- Result: class="base-class text-blue-600 bg-blue-100 rounded-lg shadow" -->
679
614
  ```
680
615
 
681
- ---
682
-
683
- ## gem: vident-tailwind
684
-
685
- # Vident::Tailwind
686
- Short description and motivation.
616
+ ### Tailwind CSS Integration
687
617
 
688
- ### Usage
689
- How to use my plugin.
690
-
691
- ### Installation
692
- Add this line to your application's Gemfile:
618
+ Vident includes built-in support for Tailwind CSS class merging when the `tailwind_merge` gem is available:
693
619
 
694
620
  ```ruby
695
- gem "vident-tailwind"
696
- ```
697
-
698
- And then execute:
699
- ```bash
700
- $ bundle
701
- ```
702
-
703
- Or install it yourself as:
704
- ```bash
705
- $ gem install vident-tailwind
621
+ class TailwindComponent < Vident::ViewComponent::Base
622
+ prop :size, Symbol, default: :medium
623
+
624
+ private
625
+
626
+ def element_classes
627
+ # Conflicts with size_class will be resolved automatically
628
+ "p-2 text-sm #{size_class}"
629
+ end
630
+
631
+ def size_class
632
+ case @size
633
+ when :small then "p-1 text-xs"
634
+ when :large then "p-4 text-lg"
635
+ else "p-2 text-base"
636
+ end
637
+ end
638
+ end
706
639
  ```
707
640
 
708
- ---
709
-
710
- ## gem: vident-typed-minitest
711
-
712
- # Vident::Typed::Minitest
713
- Short description and motivation.
641
+ ### Component Caching
714
642
 
715
- ### Usage
716
- How to use my plugin.
717
-
718
- ### Installation
719
- Add this line to your application's Gemfile:
643
+ Enable fragment caching for expensive components:
720
644
 
721
645
  ```ruby
722
- gem "vident-typed-minitest"
723
- ```
724
-
725
- And then execute:
726
- ```bash
727
- $ bundle
646
+ class ExpensiveComponent < Vident::ViewComponent::Base
647
+ include Vident::Caching
648
+
649
+ with_cache_key :to_h # Cache based on all attributes
650
+ # or
651
+ with_cache_key :id, :updated_at # Cache based on specific attributes
652
+ end
728
653
  ```
729
654
 
730
- Or install it yourself as:
731
- ```bash
732
- $ gem install vident-typed-minitest
655
+ ```erb
656
+ <% cache component.cache_key do %>
657
+ <%= render component %>
658
+ <% end %>
733
659
  ```
734
660
 
735
- ---
736
-
737
- ## gem: vident-typed-phlex
738
-
739
- # Vident::Typed::Phlex
740
-
741
- Adds typed attributes to Vident Phlex based components.
742
-
743
- ```ruby
744
- class ApplicationComponent < ::Vident::Typed::Phlex::HTML
745
- end
746
- ```
747
661
 
748
- For more details see [vident](https://github.com/stevegeek/vident).
662
+ ## Testing
749
663
 
750
- ### Usage
751
- How to use my plugin.
664
+ Vident components work seamlessly with testing frameworks that support ViewComponent or Phlex.
752
665
 
753
- ### Installation
754
- Add this line to your application's Gemfile:
666
+ ## Development
755
667
 
756
- ```ruby
757
- gem "vident-typed-phlex"
758
- ```
668
+ ### Running Tests
759
669
 
760
- And then execute:
761
670
  ```bash
762
- $ bundle
671
+ # Run all tests
672
+ bin/rails test
763
673
  ```
764
674
 
765
- Or install it yourself as:
766
- ```bash
767
- $ gem install vident-typed-phlex
768
- ```
675
+ ### Local Development
769
676
 
770
- ---
677
+ ```bash
678
+ # Clone the repository
679
+ git clone https://github.com/stevegeek/vident.git
680
+ cd vident
771
681
 
682
+ # Install dependencies
683
+ bundle install
772
684
 
773
- ## gem: vident-typed
685
+ # Run the dummy app
686
+ cd test/dummy
687
+ rails s
688
+ ```
774
689
 
775
- # Vident::Typed
776
- Short description and motivation.
690
+ ## Contributing
777
691
 
778
- ### Usage
779
- How to use my plugin.
692
+ 1. Fork the repository
693
+ 2. Create your feature branch (`git checkout -b feature/my-new-feature`)
694
+ 3. Write tests for your changes
695
+ 4. Commit your changes (`git commit -am 'Add new feature'`)
696
+ 5. Push to the branch (`git push origin feature/my-new-feature`)
697
+ 6. Create a Pull Request
780
698
 
781
- ### Installation
782
- Add this line to your application's Gemfile:
699
+ ## License
783
700
 
784
- ```ruby
785
- gem "vident-typed"
786
- ```
701
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
787
702
 
788
- And then execute:
789
- ```bash
790
- $ bundle
791
- ```
703
+ ## Credits
792
704
 
793
- Or install it yourself as:
794
- ```bash
795
- $ gem install vident-typed
796
- ```
705
+ Vident is maintained by [Stephen Ierodiaconou](https://github.com/stevegeek).
797
706
 
798
- ---
707
+ Special thanks to the ViewComponent and Phlex communities for their excellent component frameworks that Vident builds upon.