vident 1.0.0.beta2 → 1.0.1
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 +33 -1
- data/README.md +171 -17
- data/lib/generators/vident/install/install_generator.rb +53 -0
- data/lib/generators/vident/install/templates/vident.rb +20 -0
- data/lib/vident/caching.rb +3 -9
- data/lib/vident/child_element_helper.rb +22 -21
- data/lib/vident/component.rb +3 -10
- data/lib/vident/component_attribute_resolver.rb +21 -36
- data/lib/vident/component_class_lists.rb +8 -4
- data/lib/vident/stable_id.rb +48 -17
- data/lib/vident/stimulus/naming.rb +19 -0
- data/lib/vident/stimulus/primitive.rb +38 -0
- data/lib/vident/stimulus.rb +31 -0
- data/lib/vident/stimulus_action.rb +58 -23
- data/lib/vident/stimulus_attribute_base.rb +27 -23
- data/lib/vident/stimulus_attributes.rb +56 -185
- data/lib/vident/stimulus_builder.rb +66 -87
- data/lib/vident/stimulus_class.rb +3 -9
- data/lib/vident/stimulus_class_collection.rb +1 -5
- data/lib/vident/stimulus_collection_base.rb +4 -12
- data/lib/vident/stimulus_component.rb +8 -7
- data/lib/vident/stimulus_controller.rb +10 -13
- data/lib/vident/stimulus_data_attribute_builder.rb +15 -74
- data/lib/vident/stimulus_helper.rb +4 -12
- data/lib/vident/stimulus_null.rb +21 -0
- data/lib/vident/stimulus_outlet.rb +3 -9
- data/lib/vident/stimulus_outlet_collection.rb +1 -5
- data/lib/vident/stimulus_param.rb +42 -0
- data/lib/vident/stimulus_param_collection.rb +11 -0
- data/lib/vident/stimulus_target.rb +7 -17
- data/lib/vident/stimulus_target_collection.rb +2 -6
- data/lib/vident/stimulus_value.rb +14 -44
- data/lib/vident/stimulus_value_collection.rb +1 -5
- data/lib/vident/tailwind.rb +0 -2
- data/lib/vident/version.rb +1 -1
- data/lib/vident.rb +7 -12
- data/skills/vident/SKILL.md +628 -0
- metadata +10 -1
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Vident
|
|
3
|
+
description: This skill should be used when writing or editing Rails view components in a project that depends on `vident`, `vident-view_component`, or `vident-phlex` — i.e. any class inheriting from `Vident::ViewComponent::Base` or `Vident::Phlex::HTML`, any paired `*_component_controller.js` Stimulus file next to such a component, or the `stimulus_*` props / `stimulus do ... end` DSL / `child_element` / `root_element` / `class_list_for_stimulus_classes` / `Vident::StimulusNull` / `Vident::StableId` APIs. It also covers the `bin/rails generate vident:install` initializer and the per-request ID seeding it installs on `ApplicationController`.
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Vident
|
|
8
|
+
|
|
9
|
+
Vident is a thin layer on top of ViewComponent / Phlex that gives a component three things Stimulus alone does not: **typed props** (via Literal), a **declarative Ruby DSL** that compiles down to the `data-*` attributes Stimulus expects, and **first-class outlets** including a host/child self-registration pattern. Every component also comes with a **stable, deterministic element id** system so HTML output is etag-stable across requests.
|
|
10
|
+
|
|
11
|
+
A Vident component is always a class with props, a single `root_element`, and (optionally) a `stimulus do ... end` block that declares the controllers, actions, targets, values, classes and outlets its paired JavaScript file needs.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Stimulus → Vident mapping
|
|
16
|
+
|
|
17
|
+
For each Stimulus primitive: first the Stimulus contract, then the Ruby declaration that produces it, then the HTML it emits.
|
|
18
|
+
|
|
19
|
+
Identifier conventions used throughout this doc:
|
|
20
|
+
|
|
21
|
+
- A component class `Foo::BarComponent` has **stimulus identifier** `foo--bar-component` (namespace separators become `--`, CamelCase becomes kebab-case).
|
|
22
|
+
- The **implied controller** inside a component's DSL is that component's own identifier. Every DSL entry binds to it unless explicitly redirected.
|
|
23
|
+
- A Ruby symbol name is `.camelize(:lower)`-d before being emitted (so `:my_thing` → `myThing` in JS).
|
|
24
|
+
- A Ruby `"path/to/thing"` string is `stimulize_path`-d before being emitted (so `"admin/users"` → `admin--users`).
|
|
25
|
+
|
|
26
|
+
### 1.1 Controllers / identifiers
|
|
27
|
+
|
|
28
|
+
Stimulus: `data-controller="foo bar"` attaches one instance each of `foo` and `bar` to the element and its subtree.
|
|
29
|
+
|
|
30
|
+
Vident: every component attaches *itself* as a controller on its root element by default. Extra controllers come from the `stimulus_controllers:` prop or `root_element_attributes`.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class Admin::UserCardComponent < Vident::Phlex::HTML
|
|
34
|
+
# No DSL needed for the implied controller — it's automatic.
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```html
|
|
39
|
+
<div class="admin--user-card-component" data-controller="admin--user-card-component" id="...">…</div>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
To attach extra controllers, or to opt out:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
class Foo::BarComponent < Vident::ViewComponent::Base
|
|
46
|
+
no_stimulus_controller # don't emit the implied `data-controller`
|
|
47
|
+
|
|
48
|
+
# or keep the implied one AND add more:
|
|
49
|
+
def root_element_attributes
|
|
50
|
+
{ stimulus_controllers: ["other/widget", :tooltip] }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Cross-controller references elsewhere (actions/targets/values/classes/outlets) use the `"path/to/controller"` **string** form — Vident stimulizes it for you.
|
|
56
|
+
|
|
57
|
+
### 1.2 Actions
|
|
58
|
+
|
|
59
|
+
Stimulus descriptor: `event->controller#method`, with optional modifiers (`:once`, `:prevent`, `keydown.ctrl+a`, `@window`, etc.). Stimulus encodes them as space-separated tokens in `data-action="..."`.
|
|
60
|
+
|
|
61
|
+
Vident `actions` DSL entries (all of these work inside `stimulus do ... end`):
|
|
62
|
+
|
|
63
|
+
| Ruby | Emits |
|
|
64
|
+
| ------------------------------------------- | -------------------------------------------------- |
|
|
65
|
+
| `:my_thing` | `implied#myThing` (no explicit event) |
|
|
66
|
+
| `[:click, :my_thing]` | `click->implied#myThing` |
|
|
67
|
+
| `[:click, "other/ctrl", :my_thing]` | `click->other--ctrl#myThing` |
|
|
68
|
+
| `"click->other--ctrl#myThing"` | pass-through, parsed into its parts |
|
|
69
|
+
| `{event: :click, method: :submit, options: [:once, :prevent]}` | `click:once:prevent->implied#submit` |
|
|
70
|
+
| `Vident::StimulusAction::Descriptor.new(event: :click, method: :submit, options: [:once])` | same — typed data object, Hash is sugar |
|
|
71
|
+
| `-> { [:click, :my_thing] if @editable }` | proc, evaluated in component instance; `nil`/`false` returns drop the entry |
|
|
72
|
+
|
|
73
|
+
**Modifiers via the Hash / Descriptor form.** Accepted keys on the hash (and the `Descriptor` data class):
|
|
74
|
+
|
|
75
|
+
| Key | Type | Emits |
|
|
76
|
+
| ------------ | ----------------- | ---------------------------------------- |
|
|
77
|
+
| `event:` | Symbol / String | prepends `event->` |
|
|
78
|
+
| `method:` | Symbol / String | the `#method` part (required) |
|
|
79
|
+
| `controller:`| String path | routes to another controller |
|
|
80
|
+
| `options:` | `Array<Symbol>` from `:once`, `:prevent`, `:stop`, `:passive`, `:"!passive"`, `:capture`, `:self` | `:once:prevent…` suffix on event |
|
|
81
|
+
| `keyboard:` | String like `"ctrl+a"` | `.ctrl+a` suffix on event filter |
|
|
82
|
+
| `window:` | Boolean | `@window` suffix on event |
|
|
83
|
+
|
|
84
|
+
Unknown option symbols raise `ArgumentError`. Use the Hash form for the common case; use `Vident::StimulusAction::Descriptor.new(...)` when you want a typed, passable value object (reusable across components, shared helpers).
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
actions({event: :keydown, method: :on_escape, keyboard: "esc", options: [:prevent]})
|
|
88
|
+
# => keydown.esc:prevent->implied#onEscape
|
|
89
|
+
|
|
90
|
+
actions({event: :click, method: :handle, controller: "dialog/open", window: true})
|
|
91
|
+
# => click@window->dialog--open#handle
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
stimulus do
|
|
96
|
+
actions :toggle # implied#toggle
|
|
97
|
+
actions [:click, :submit], [:input, :on_input] # multiple entries
|
|
98
|
+
actions [:click, "dialog/open", :show] # cross-controller
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Emits (combined on one element):
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
data-action="implied#toggle click->implied#submit input->implied#onInput click->dialog--open#show"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The same shapes are accepted by the `stimulus_actions:` prop, `child_element(stimulus_action: …)`, and the `as_stimulus_action(s)` ERB helpers.
|
|
109
|
+
|
|
110
|
+
**Scoped events on window.** To listen for an event *dispatched by another component*, reference the dispatcher's class:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
actions -> { [OtherComponent.stimulus_scoped_event_on_window(:data_ready), :handle_ready] }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`OtherComponent.stimulus_scoped_event_on_window(:data_ready)` returns the symbol `:"other-component:dataReady@window"`. The action parser treats the whole symbol as the event name, so this yields `data-action="other-component:dataReady@window->implied#handleReady"`. On the dispatcher's JS side, `this.dispatch("dataReady", { target: window })` produces exactly that event type because Stimulus prefixes dispatches with the dispatcher's identifier. **Always call the class method on the dispatcher, never on the listener.**
|
|
117
|
+
|
|
118
|
+
There is also `stimulus_scoped_event(:name)` (no `@window`) when the event bubbles naturally and doesn't need a global listener.
|
|
119
|
+
|
|
120
|
+
### 1.3 Targets
|
|
121
|
+
|
|
122
|
+
Stimulus: `data-<identifier>-target="name"` on an element exposes `this.nameTarget` / `this.nameTargets` / `this.hasNameTarget` in the JS controller. CamelCase names in JS / kebab-case in HTML.
|
|
123
|
+
|
|
124
|
+
Vident `targets` DSL:
|
|
125
|
+
|
|
126
|
+
| Ruby | Emits |
|
|
127
|
+
| ------------------------------ | -------------------------------------------------- |
|
|
128
|
+
| `:button` | `data-implied-target="button"` on the root |
|
|
129
|
+
| `["other/ctrl", :row]` | `data-other--ctrl-target="row"` on the root |
|
|
130
|
+
| Same shapes on `child_element` | `data-implied-target="..."` on the child |
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
stimulus do
|
|
134
|
+
targets :body, :footer
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# and in the view:
|
|
138
|
+
card.child_element(:button, stimulus_target: :promote_button) { "Promote" }
|
|
139
|
+
# => <button data-implied-target="promoteButton">Promote</button>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A proc form `-> { cond ? :foo : nil }` is supported; returning `nil` drops the entry.
|
|
143
|
+
|
|
144
|
+
### 1.4 Values
|
|
145
|
+
|
|
146
|
+
Stimulus: `data-<identifier>-<name>-value="..."` with types `String | Number | Boolean | Object | Array`. In JS: `this.nameValue` / `this.nameValue=` / `this.hasNameValue`. Name is camelCase in JS, kebab-case in HTML.
|
|
147
|
+
|
|
148
|
+
Vident has three entry points, all composable:
|
|
149
|
+
|
|
150
|
+
**(a) `values(key: value, …)` in the DSL.** Static values, or procs evaluated in the component instance at render time:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
stimulus do
|
|
154
|
+
values initial_open: false,
|
|
155
|
+
status_label: -> { @status.to_s.capitalize }
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**(b) `values_from_props :a, :b, …` in the DSL.** Mirrors a typed prop straight through — the emitted value is the prop's current `@ivar`.
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
prop :release_id, Integer
|
|
163
|
+
stimulus do
|
|
164
|
+
values_from_props :release_id
|
|
165
|
+
end
|
|
166
|
+
# => data-implied-release-id-value="42"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**(c) `stimulus_values:` prop / `child_element(stimulus_value(s): …)`.** Array form required for cross-controller:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
stimulus_values: [
|
|
173
|
+
[:foo, "bar"], # implied-foo-value="bar"
|
|
174
|
+
["other/ctrl", :baz, 42], # other--ctrl-baz-value="42"
|
|
175
|
+
]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Serialization: Booleans/Numbers stringify directly; `Array` / `Hash` serialize as JSON. `String` stringifies.
|
|
179
|
+
|
|
180
|
+
**The nil rule.** A `nil` resolved value (from a proc or static) **omits the data attribute entirely**, letting Stimulus use its per-type default. Never rely on `nil` becoming `""`. If you need explicit JSON `null` on the JS side for an `Object`- or `Array`-typed value, return the `Vident::StimulusNull` sentinel, which serializes to the literal string `"null"` that Stimulus's Object parser feeds to `JSON.parse`:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
values release: -> { @selected ? @selected.to_h : Vident::StimulusNull }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Use `StimulusNull` *only* for Object/Array values — for `String`/`Number`/`Boolean` it reads as garbage ("null" / NaN / truthy).
|
|
187
|
+
|
|
188
|
+
### 1.5 Value-change callbacks
|
|
189
|
+
|
|
190
|
+
Stimulus: defining `fooValueChanged(newValue, previousValue)` on a controller fires once on connect and on every subsequent change to `this.fooValue`.
|
|
191
|
+
|
|
192
|
+
Vident: **pure JS, no Ruby-side hook.** Vident only emits the attribute — the callback is written in the paired `_controller.js`. See section 7.
|
|
193
|
+
|
|
194
|
+
### 1.6 Classes
|
|
195
|
+
|
|
196
|
+
Stimulus: `data-<identifier>-<name>-class="foo bar"` exposes `this.nameClass` (first token) / `this.nameClasses` (array) in the controller. Classes are applied manually via `classList` in JS.
|
|
197
|
+
|
|
198
|
+
Vident `classes` DSL:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
stimulus do
|
|
202
|
+
classes loading: "opacity-50 cursor-wait", # static
|
|
203
|
+
status: -> { # proc
|
|
204
|
+
case @status
|
|
205
|
+
when :deployed then "border-green-500 bg-green-50"
|
|
206
|
+
when :failed then "border-red-500 bg-red-50"
|
|
207
|
+
else "border-yellow-400 bg-yellow-50"
|
|
208
|
+
end
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Emits:
|
|
214
|
+
|
|
215
|
+
```html
|
|
216
|
+
data-implied-loading-class="opacity-50 cursor-wait"
|
|
217
|
+
data-implied-status-class="border-green-500 bg-green-50"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
These only tell the JS controller *which* classes to toggle; they do **not** apply them to the first render. For SSR initial state, inline the resolved value into `class=` via `class_list_for_stimulus_classes(:status, :loading)` (see section 4).
|
|
221
|
+
|
|
222
|
+
### 1.7 Outlets
|
|
223
|
+
|
|
224
|
+
Stimulus: `data-<identifier>-<outlet-name>-outlet="<css-selector>"` attaches matching controller instances as `this.nameOutlet` / `this.nameOutlets` / `this.nameOutletElement(s)` / `this.hasNameOutlet`. Outlet names are the kebab-case *identifier* of the outlet controller.
|
|
225
|
+
|
|
226
|
+
Vident has three forms.
|
|
227
|
+
|
|
228
|
+
**(a) DSL on the root:**
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
stimulus do
|
|
232
|
+
outlets modal: ".modal", user_status: ".online-user"
|
|
233
|
+
outlets({"admin--users" => ".admin-user"}) # positional-hash for names containing `--`
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**(b) `stimulus_outlets:` prop / `root_element_attributes` / `child_element(stimulus_outlet(s): …)`:**
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
stimulus_outlets: [
|
|
241
|
+
[:modal, ".modal"], # implied controller
|
|
242
|
+
["admin/users", :row, ".user-row"], # cross-controller
|
|
243
|
+
:user_status, # auto-selector: [data-controller~=user-status]
|
|
244
|
+
other_component_instance, # #<id> [data-controller~=<other identifier>]
|
|
245
|
+
]
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**(c) Child self-registers on a host via `stimulus_outlet_host:`.** Every Vident component inherits a `stimulus_outlet_host` prop. Passing a parent component at render time calls `host.add_stimulus_outlets(self)` in `prepare_stimulus_collections`, so the host's root gets the outlet attribute without enumerating children in its DSL:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
render PageComponent.new do |page|
|
|
252
|
+
@releases.each do |r|
|
|
253
|
+
render ReleaseCardComponent.new(**r, stimulus_outlet_host: page)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The host's JS still declares the outlet name in `static outlets = ["release-card-component"]`.
|
|
259
|
+
|
|
260
|
+
Emits on host root (example):
|
|
261
|
+
|
|
262
|
+
```html
|
|
263
|
+
data-page-component-release-card-component-outlet="#page-123 [data-controller~=release-card-component]"
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### 1.8 Lifecycle callbacks
|
|
267
|
+
|
|
268
|
+
Stimulus: `initialize()`, `connect()`, `disconnect()`, `<name>TargetConnected(el)`, `<name>TargetDisconnected(el)`, `<name>OutletConnected(ctrl, el)`, `<name>OutletDisconnected(ctrl, el)`.
|
|
269
|
+
|
|
270
|
+
Vident: **pure JS, no Ruby-side hook.** Write them in the paired `_controller.js`. Vident's `after_component_initialize` is a Ruby-side post-props-assigned hook on the component — unrelated to the Stimulus lifecycle.
|
|
271
|
+
|
|
272
|
+
### 1.9 Action params
|
|
273
|
+
|
|
274
|
+
Stimulus: `data-<identifier>-<name>-param="value"` lives on an element. Any action handler whose event fires on or bubbles through that element reads the values as `event.params.<name>` (auto-typecast to Number/String/Object/Boolean).
|
|
275
|
+
|
|
276
|
+
Vident has three entry points, all mirroring `values`:
|
|
277
|
+
|
|
278
|
+
**(a) `params(key: value, …)` in the DSL.** Static values or procs evaluated in the component instance:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
stimulus do
|
|
282
|
+
actions [:click, :promote]
|
|
283
|
+
params release_id: -> { @release_id }, kind: "promote"
|
|
284
|
+
end
|
|
285
|
+
# => data-implied-release-id-param="42" data-implied-kind-param="promote"
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**(b) `stimulus_params:` prop / `child_element(stimulus_params: …)`.** The common "one button, one action, params for that action" case lives here — co-located with the `stimulus_action:` it informs:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
card.child_element(:button,
|
|
292
|
+
stimulus_action: [:click, :promote],
|
|
293
|
+
stimulus_params: { release_id: @release_id, kind: "promote" })
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**(c) Array form on the prop for cross-controller:**
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
stimulus_params: [
|
|
300
|
+
[:release_id, 42], # implied-release-id-param="42"
|
|
301
|
+
["other/ctrl", :scope, "full"], # other--ctrl-scope-param="full"
|
|
302
|
+
]
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**Element-scoped, not action-scoped.** In Stimulus, params live on the element, not on an individual action. Multiple actions on the same element share the same params. Vident's DSL matches this: `params` is a sibling of `actions`, not nested inside it.
|
|
306
|
+
|
|
307
|
+
Inline helper (ERB): `as_stimulus_param(:release_id, 42)` / `as_stimulus_params({release_id: 42})`.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## 2. Component scaffolding
|
|
312
|
+
|
|
313
|
+
Pick the right base class:
|
|
314
|
+
|
|
315
|
+
- **ViewComponent:** `class Foo::BarComponent < Vident::ViewComponent::Base`
|
|
316
|
+
- **Phlex:** `class Foo::BarComponent < Vident::Phlex::HTML`
|
|
317
|
+
|
|
318
|
+
Both include `Vident::Component`, which brings in the Stimulus DSL, class-list builder, caching, and child-element helper.
|
|
319
|
+
|
|
320
|
+
### Props
|
|
321
|
+
|
|
322
|
+
Defined with the Literal DSL:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
prop :title, String # required
|
|
326
|
+
prop :count, Integer, default: 0 # with default
|
|
327
|
+
prop :url, _Nilable(String) # optional / nilable
|
|
328
|
+
prop :variant, _Union(:primary, :secondary), default: :primary
|
|
329
|
+
prop :items, _Array(Hash), default: -> { [] } # callable defaults must be lambdas
|
|
330
|
+
prop :open, _Boolean, default: false # generates an `open?` predicate
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Props become `@ivar`s at init time. To also expose a reader method, declare the prop with `reader: :public`.
|
|
334
|
+
|
|
335
|
+
### Built-in props every component inherits
|
|
336
|
+
|
|
337
|
+
From `Vident::Component`:
|
|
338
|
+
|
|
339
|
+
- `element_tag` — `Symbol`, root tag type, default `:div`.
|
|
340
|
+
- `id` — `String?`. Auto-generated via `StableId` if omitted. The generated form is `<component-name>-<sequence>`.
|
|
341
|
+
- `classes` — `String | Array(String)`. Appended to the root element's `class=`.
|
|
342
|
+
- `html_options` — `Hash`. Merged onto the root element; highest precedence.
|
|
343
|
+
|
|
344
|
+
From `Vident::StimulusComponent`:
|
|
345
|
+
|
|
346
|
+
- `stimulus_controllers` — `Array(String | Symbol | StimulusController | Collection)`. Defaults to `[default_controller_path]` unless `no_stimulus_controller` is declared.
|
|
347
|
+
- `stimulus_actions`, `stimulus_targets`, `stimulus_values`, `stimulus_classes`, `stimulus_outlets` — Array / Hash props matching the shapes described in section 1.
|
|
348
|
+
- `stimulus_outlet_host` — optional `Vident::Component`; activates child→host outlet self-registration.
|
|
349
|
+
|
|
350
|
+
### `root_element` and `root_element_attributes`
|
|
351
|
+
|
|
352
|
+
Every component renders **exactly one** root element via `root_element`. Override `root_element_attributes` (returns a Hash) to set the tag, add HTML options, or push stimulus attributes declaratively:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def root_element_attributes
|
|
358
|
+
{
|
|
359
|
+
element_tag: @url ? :a : :button, # default :div
|
|
360
|
+
html_options: { href: @url }.compact,
|
|
361
|
+
# stimulus_actions:, stimulus_targets:, stimulus_values:, stimulus_classes:,
|
|
362
|
+
# stimulus_controllers:, stimulus_outlets: — all accepted here.
|
|
363
|
+
}
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`root_element_attributes` attributes have **higher precedence** than `stimulus do ... end` DSL entries, so a hardcoded `html_options[:class]` wins over `classes:` passed at render.
|
|
368
|
+
|
|
369
|
+
Phlex template:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
def view_template
|
|
373
|
+
root_element(class: "space-y-4") do |component|
|
|
374
|
+
h2 { @title }
|
|
375
|
+
component.child_element(:button, stimulus_action: [:click, :promote]) { "Promote" }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
ViewComponent template (`.html.erb`):
|
|
381
|
+
|
|
382
|
+
```erb
|
|
383
|
+
<%= root_element(class: "space-y-4") do |component| %>
|
|
384
|
+
<h2><%= @title %></h2>
|
|
385
|
+
<%= component.child_element(:button, stimulus_action: [:click, :promote]) { "Promote" } %>
|
|
386
|
+
<% end %>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### `child_element`
|
|
390
|
+
|
|
391
|
+
Renders a child tag with `stimulus_*` kwargs compiled into `data-*` attributes. Singular (`stimulus_action:`, `stimulus_target:`, etc.) take one entry; plural (`stimulus_actions:`, etc.) take an Enumerable. Passing a non-Enumerable to a plural raises. Other kwargs pass through as HTML options.
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
component.child_element(
|
|
395
|
+
:button,
|
|
396
|
+
stimulus_action: [:click, :submit],
|
|
397
|
+
stimulus_target: :submit_button,
|
|
398
|
+
stimulus_value: [:label, "Go"],
|
|
399
|
+
type: "button",
|
|
400
|
+
class: "rounded bg-blue-600 text-white"
|
|
401
|
+
) { "Go" }
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Inline `as_stimulus_*` helpers (ViewComponent / ERB)
|
|
405
|
+
|
|
406
|
+
When handwriting HTML inside ERB instead of using `child_element`, emit just the data attributes with the inline helpers on the component:
|
|
407
|
+
|
|
408
|
+
```erb
|
|
409
|
+
<input <%= component.as_stimulus_target(:search) %> type="search">
|
|
410
|
+
<button <%= component.as_stimulus_action([:click, :greet]) %>>Greet</button>
|
|
411
|
+
<div <%= component.as_stimulus_values(%i[count label]) %>></div>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Plural (`as_stimulus_targets`, `as_stimulus_actions`, `as_stimulus_values`, `as_stimulus_classes`, `as_stimulus_outlets`, `as_stimulus_controllers`) and singular variants exist for every attribute kind. These helpers are defined on `Vident::ViewComponent::Base`; for Phlex, use `child_element` or compose directly.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## 3. `stimulus do ... end` block
|
|
419
|
+
|
|
420
|
+
Opens a `Vident::StimulusBuilder` instance scoped to the class. It supports `actions`, `targets`, `values`, `values_from_props`, `classes`, `outlets`. Multiple `stimulus do` blocks on the same class are merged; a subclass's block is merged with its superclass's (subclass entries appended, values/classes/outlets merged by key, subclass wins on conflicts).
|
|
421
|
+
|
|
422
|
+
Procs passed anywhere in the DSL are evaluated via `instance_exec` on the **component instance** at render time, so they see `@ivars` and public/private instance methods.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## 4. Classes and SSR initial state
|
|
427
|
+
|
|
428
|
+
The `classes` DSL only writes `data-*-class` attributes for the JS to read. The initial DOM is still whatever you pass to `class:`. To inline the resolved stimulus-class values into the first render, call `class_list_for_stimulus_classes(*names)` from the view and interpolate it:
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
# On the root element:
|
|
432
|
+
root_element(class: "base-classes #{class_list_for_stimulus_classes(:status)}")
|
|
433
|
+
|
|
434
|
+
# On a child element:
|
|
435
|
+
card.child_element(:span, class: "ml-4 #{class_list_for_stimulus_classes(:status)}")
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
It returns a space-joined String of the resolved classes for the named stimulus-class entries only. The builder deduplicates and (if `tailwind_merge` is available) Tailwind-merges the whole class list on the root element.
|
|
439
|
+
|
|
440
|
+
### Class-list precedence on root
|
|
441
|
+
|
|
442
|
+
From lowest to highest:
|
|
443
|
+
|
|
444
|
+
1. `component_name` is always included as the first class (so every instance carries `foo--bar-component` for CSS hooks).
|
|
445
|
+
2. `root_element_classes` (override on the class) — only if no `root_element_attributes[:classes]` / `html_options[:class]`.
|
|
446
|
+
3. `root_element_attributes[:classes]` — only if no `html_options[:class]`.
|
|
447
|
+
4. `root_element(class: …)` — passed in the template.
|
|
448
|
+
5. `html_options[:class]` (from the prop) — **highest**.
|
|
449
|
+
6. `classes:` (the prop) is **always** appended on top.
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## 5. StableId: deterministic element IDs
|
|
454
|
+
|
|
455
|
+
`Vident::StableId.strategy` is a callable that takes the current-thread's sequence generator and returns the next id. Two built-in strategies:
|
|
456
|
+
|
|
457
|
+
- `Vident::StableId::STRICT` — raises if no generator is set. Use in development/production.
|
|
458
|
+
- `Vident::StableId::RANDOM_FALLBACK` — falls back to `Random.hex(16)` when no generator is set. Use in test/previews/mailers.
|
|
459
|
+
|
|
460
|
+
`bin/rails generate vident:install` writes `config/initializers/vident.rb`:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
Vident::StableId.strategy = Rails.env.test? ?
|
|
464
|
+
Vident::StableId::RANDOM_FALLBACK :
|
|
465
|
+
Vident::StableId::STRICT
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
…and patches `ApplicationController`:
|
|
469
|
+
|
|
470
|
+
```ruby
|
|
471
|
+
before_action { Vident::StableId.set_current_sequence_generator(seed: request.fullpath) }
|
|
472
|
+
after_action { Vident::StableId.clear_current_sequence_generator }
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Same URL → same seed → same IDs across requests, so etags are stable.
|
|
476
|
+
|
|
477
|
+
### Rendering outside a request
|
|
478
|
+
|
|
479
|
+
Jobs, mailers, script previews, and Metal endpoints don't hit `ApplicationController`. Wrap with:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
Vident::StableId.with_sequence_generator(seed: "some-unique-key") { render ... }
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
…or set the strategy to `RANDOM_FALLBACK` for that context. A bare `StableId::GeneratorNotSetError` in production means the `before_action` was bypassed.
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## 6. Component-level extras
|
|
490
|
+
|
|
491
|
+
- **`after_component_initialize`** — override in your component; runs after props are assigned and Vident has prepared its stimulus collections. Don't override `after_initialize` unless you `super` — Literal calls it to wire everything up.
|
|
492
|
+
- **`component_name` / `stimulus_identifier`** — class method and instance method; the kebab-case/`--`-separated identifier. Used for outlet auto-selectors, scoped event names, and the default class on the root.
|
|
493
|
+
- **Caching** (`include Vident::Caching` + `with_cache_key :attr1, :attr2`) — declares attributes that feed `cache_key`. Combined with a template mtime so edits bust the cache. `depends_on(OtherComponent, …)` chains subcomponent mtimes into the key.
|
|
494
|
+
- **`clone(overrides = {})`** — returns a new instance with merged props.
|
|
495
|
+
- **Phlex tag safety** — `Vident::Phlex::HTML` validates every `child_element` tag name against a whitelist; passing an unknown tag raises.
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## 7. JavaScript side of the handshake
|
|
500
|
+
|
|
501
|
+
Each component has a paired `_controller.js` sitting next to the Ruby file:
|
|
502
|
+
|
|
503
|
+
```
|
|
504
|
+
app/components/dashboard/card_component.rb
|
|
505
|
+
app/components/dashboard/card_component_controller.js
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Stimulus auto-registration (`eagerLoadControllersFrom("app_components", application)` in your `application.js`) maps the file's location under `app/components/` to the identifier Vident uses: `dashboard--card-component`. Subclasses of `Vident::ViewComponent::Base` / `Vident::Phlex::HTML` don't need any extra wiring.
|
|
509
|
+
|
|
510
|
+
Typical controller:
|
|
511
|
+
|
|
512
|
+
```js
|
|
513
|
+
import { Controller } from "@hotwired/stimulus"
|
|
514
|
+
|
|
515
|
+
export default class extends Controller {
|
|
516
|
+
static values = { name: String, releaseId: Number, status: String }
|
|
517
|
+
static targets = ["promoteButton", "cancelButton"]
|
|
518
|
+
static outlets = ["dashboard--release-card-component"]
|
|
519
|
+
static classes = ["status"]
|
|
520
|
+
|
|
521
|
+
// Lifecycle
|
|
522
|
+
connect() { /* element in DOM */ }
|
|
523
|
+
disconnect() { /* element removed */ }
|
|
524
|
+
statusValueChanged(n, prev) { /* reactive */ }
|
|
525
|
+
promoteButtonTargetConnected(el) { /* target appeared */ }
|
|
526
|
+
dashboardReleaseCardComponentOutletConnected(ctrl, el) { /* outlet attached */ }
|
|
527
|
+
|
|
528
|
+
promote() {
|
|
529
|
+
this.promoteButtonTarget.disabled = true
|
|
530
|
+
this.dispatch("promoted", {
|
|
531
|
+
target: window,
|
|
532
|
+
detail: { releaseId: this.releaseIdValue },
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### Dispatch / scoped event mapping
|
|
539
|
+
|
|
540
|
+
`this.dispatch("foo", { target: window, detail: … })` emits an event of type `<this-identifier>:foo` on `window`. The matching Ruby-side listener is:
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
actions -> { [OtherComponent.stimulus_scoped_event_on_window(:foo), :handle_foo] }
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
…where `OtherComponent` is the *dispatching* component class. If the event doesn't need `@window` (it bubbles naturally through the DOM), use `stimulus_scoped_event(:foo)` instead.
|
|
547
|
+
|
|
548
|
+
### Outlet lifecycle gotcha
|
|
549
|
+
|
|
550
|
+
Inside `<name>OutletConnected`, **do not iterate `this.<name>Outlets`**. Stimulus attaches outlet controllers one at a time, and the plural getter warns for every selector match whose controller hasn't yet attached. Iterate on explicit events (filter changes, user actions) by which time all siblings have connected.
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## 8. Recipes
|
|
555
|
+
|
|
556
|
+
**Click handler on the root** — `stimulus do; actions [:click, :select]; end` + `select(event) {…}` in JS.
|
|
557
|
+
|
|
558
|
+
**Click handler on a child button** — `card.child_element(:button, stimulus_action: [:click, :promote]) { "Promote" }`.
|
|
559
|
+
|
|
560
|
+
**Expose a prop to JS**:
|
|
561
|
+
```ruby
|
|
562
|
+
prop :release_id, Integer
|
|
563
|
+
stimulus do
|
|
564
|
+
values_from_props :release_id
|
|
565
|
+
end
|
|
566
|
+
```
|
|
567
|
+
```js
|
|
568
|
+
static values = { releaseId: Number }
|
|
569
|
+
promote() { console.log(this.releaseIdValue) }
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
**Toggle a class from JS**:
|
|
573
|
+
```ruby
|
|
574
|
+
stimulus do
|
|
575
|
+
classes hidden: "opacity-0 pointer-events-none"
|
|
576
|
+
end
|
|
577
|
+
```
|
|
578
|
+
```js
|
|
579
|
+
static classes = ["hidden"]
|
|
580
|
+
hide() { this.element.classList.add(...this.hiddenClasses) }
|
|
581
|
+
```
|
|
582
|
+
SSR initial state: `root_element(class: class_list_for_stimulus_classes(:hidden))`.
|
|
583
|
+
|
|
584
|
+
**Connect two components via outlets** — parent declares the outlet name, child self-registers via `stimulus_outlet_host: parent` at render time. Parent JS: `static outlets = ["child-component"]` + `childComponentConnected(ctrl, el) {…}`.
|
|
585
|
+
|
|
586
|
+
**Write a value on a different controller** — DSL: `values([["other/ctrl", :foo, "bar"]])`. Prop: `stimulus_values: [["other/ctrl", :foo, "bar"]]`.
|
|
587
|
+
|
|
588
|
+
**React to another component's dispatched event**:
|
|
589
|
+
```ruby
|
|
590
|
+
stimulus do
|
|
591
|
+
actions -> { [DispatcherComponent.stimulus_scoped_event_on_window(:updated), :on_updated] }
|
|
592
|
+
end
|
|
593
|
+
```
|
|
594
|
+
```js
|
|
595
|
+
// in dispatcher_controller.js
|
|
596
|
+
this.dispatch("updated", { target: window, detail: { /*…*/ } })
|
|
597
|
+
// in listener_controller.js
|
|
598
|
+
onUpdated(event) { /* event.detail */ }
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**Render outside a request** — `Vident::StableId.with_sequence_generator(seed: job.id) { render … }`.
|
|
602
|
+
|
|
603
|
+
**Opt out of the implied controller** — declare `no_stimulus_controller` in the class body.
|
|
604
|
+
|
|
605
|
+
**Change the root tag conditionally**:
|
|
606
|
+
```ruby
|
|
607
|
+
def root_element_attributes
|
|
608
|
+
{
|
|
609
|
+
element_tag: @url ? :a : :button,
|
|
610
|
+
html_options: { href: @url, type: @url ? nil : "button" }.compact,
|
|
611
|
+
}
|
|
612
|
+
end
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## 9. Key source files
|
|
618
|
+
|
|
619
|
+
- `lib/vident/stimulus_builder.rb` — DSL evaluator.
|
|
620
|
+
- `lib/vident/stimulus_attributes.rb` — parser for every `stimulus_*` input shape + `as_stimulus_*` helpers' backing.
|
|
621
|
+
- `lib/vident/stimulus_{action,target,value,outlet,class,controller}.rb` — value objects; read `parse_arguments` to learn the argument shapes.
|
|
622
|
+
- `lib/vident/child_element_helper.rb` — `child_element` kwargs and validation.
|
|
623
|
+
- `lib/vident/component_attribute_resolver.rb` — how DSL, props, and `root_element_attributes` compose at render time.
|
|
624
|
+
- `lib/vident/component_class_lists.rb` — `class_list_for_stimulus_classes` / `render_classes`.
|
|
625
|
+
- `lib/vident/stable_id.rb` — the StableId strategy system.
|
|
626
|
+
- `lib/vident/stimulus_null.rb` — the StimulusNull sentinel.
|
|
627
|
+
- `lib/vident/view_component/base.rb` / `lib/vident/phlex/html.rb` — framework-specific `root_element` / `child_element` backings and (ViewComponent only) `as_stimulus_*` helpers.
|
|
628
|
+
- `test/dummy/app/components/dashboard/` — canonical multi-component example (outlets, scoped events, `StimulusNull`, dynamic classes, `values_from_props`, `class_list_for_stimulus_classes`, full JS side).
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vident
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stephen Ierodiaconou
|
|
@@ -82,6 +82,8 @@ files:
|
|
|
82
82
|
- CHANGELOG.md
|
|
83
83
|
- LICENSE.txt
|
|
84
84
|
- README.md
|
|
85
|
+
- lib/generators/vident/install/install_generator.rb
|
|
86
|
+
- lib/generators/vident/install/templates/vident.rb
|
|
85
87
|
- lib/vident.rb
|
|
86
88
|
- lib/vident/caching.rb
|
|
87
89
|
- lib/vident/child_element_helper.rb
|
|
@@ -91,6 +93,9 @@ files:
|
|
|
91
93
|
- lib/vident/component_class_lists.rb
|
|
92
94
|
- lib/vident/engine.rb
|
|
93
95
|
- lib/vident/stable_id.rb
|
|
96
|
+
- lib/vident/stimulus.rb
|
|
97
|
+
- lib/vident/stimulus/naming.rb
|
|
98
|
+
- lib/vident/stimulus/primitive.rb
|
|
94
99
|
- lib/vident/stimulus_action.rb
|
|
95
100
|
- lib/vident/stimulus_action_collection.rb
|
|
96
101
|
- lib/vident/stimulus_attribute_base.rb
|
|
@@ -104,14 +109,18 @@ files:
|
|
|
104
109
|
- lib/vident/stimulus_controller_collection.rb
|
|
105
110
|
- lib/vident/stimulus_data_attribute_builder.rb
|
|
106
111
|
- lib/vident/stimulus_helper.rb
|
|
112
|
+
- lib/vident/stimulus_null.rb
|
|
107
113
|
- lib/vident/stimulus_outlet.rb
|
|
108
114
|
- lib/vident/stimulus_outlet_collection.rb
|
|
115
|
+
- lib/vident/stimulus_param.rb
|
|
116
|
+
- lib/vident/stimulus_param_collection.rb
|
|
109
117
|
- lib/vident/stimulus_target.rb
|
|
110
118
|
- lib/vident/stimulus_target_collection.rb
|
|
111
119
|
- lib/vident/stimulus_value.rb
|
|
112
120
|
- lib/vident/stimulus_value_collection.rb
|
|
113
121
|
- lib/vident/tailwind.rb
|
|
114
122
|
- lib/vident/version.rb
|
|
123
|
+
- skills/vident/SKILL.md
|
|
115
124
|
homepage: https://github.com/stevegeek/vident
|
|
116
125
|
licenses:
|
|
117
126
|
- MIT
|