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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +171 -17
  4. data/lib/generators/vident/install/install_generator.rb +53 -0
  5. data/lib/generators/vident/install/templates/vident.rb +20 -0
  6. data/lib/vident/caching.rb +3 -9
  7. data/lib/vident/child_element_helper.rb +22 -21
  8. data/lib/vident/component.rb +3 -10
  9. data/lib/vident/component_attribute_resolver.rb +21 -36
  10. data/lib/vident/component_class_lists.rb +8 -4
  11. data/lib/vident/stable_id.rb +48 -17
  12. data/lib/vident/stimulus/naming.rb +19 -0
  13. data/lib/vident/stimulus/primitive.rb +38 -0
  14. data/lib/vident/stimulus.rb +31 -0
  15. data/lib/vident/stimulus_action.rb +58 -23
  16. data/lib/vident/stimulus_attribute_base.rb +27 -23
  17. data/lib/vident/stimulus_attributes.rb +56 -185
  18. data/lib/vident/stimulus_builder.rb +66 -87
  19. data/lib/vident/stimulus_class.rb +3 -9
  20. data/lib/vident/stimulus_class_collection.rb +1 -5
  21. data/lib/vident/stimulus_collection_base.rb +4 -12
  22. data/lib/vident/stimulus_component.rb +8 -7
  23. data/lib/vident/stimulus_controller.rb +10 -13
  24. data/lib/vident/stimulus_data_attribute_builder.rb +15 -74
  25. data/lib/vident/stimulus_helper.rb +4 -12
  26. data/lib/vident/stimulus_null.rb +21 -0
  27. data/lib/vident/stimulus_outlet.rb +3 -9
  28. data/lib/vident/stimulus_outlet_collection.rb +1 -5
  29. data/lib/vident/stimulus_param.rb +42 -0
  30. data/lib/vident/stimulus_param_collection.rb +11 -0
  31. data/lib/vident/stimulus_target.rb +7 -17
  32. data/lib/vident/stimulus_target_collection.rb +2 -6
  33. data/lib/vident/stimulus_value.rb +14 -44
  34. data/lib/vident/stimulus_value_collection.rb +1 -5
  35. data/lib/vident/tailwind.rb +0 -2
  36. data/lib/vident/version.rb +1 -1
  37. data/lib/vident.rb +7 -12
  38. data/skills/vident/SKILL.md +628 -0
  39. 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.0.beta2
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