Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


## [3.1.0] - 2026-05-26

### Added

- Cross-controller parser forms accept a component class (or instance) wherever they accept a controller-path String — e.g. `stimulus_target: [Row, :summary]`, `stimulus_controllers: [Row]`, `stimulus_action: [:click, Row, :toggle]` — so references to another component's controller stay refactor-safe instead of hard-coded identifier strings. The same holds for the class-level builders (`Parent.stimulus_target(Row, :summary)`); the String cross-controller form is still rejected there.


## [3.0.0] - 2026-05-04

A bare `String` now means the same thing across every Stimulus primitive — a controller path. The 2.x ambiguity where a `String` could variously mean controller path, outlet name, CSS selector, or fully-qualified action descriptor (depending on primitive and position) is gone, closing a class of silent-failure footguns. See `UPGRADING.md` "Upgrading to Vident 3.0" for symptom → fix on each breaking change.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ class CustomComponent < Vident::ViewComponent::Base
end
```

All stimulus props accept Symbol paths as well as Strings (e.g. `stimulus_controllers: [:custom, :"admin/users"]`). `stimulus_values:` and `stimulus_classes:` additionally accept Array entries (for cross-controller: `[["admin/users", :name, "value"]]`) and pre-built `Vident::Stimulus::Value` / `Vident::Stimulus::ClassMap` instances, so you can compose attribute sets outside the component and pass them in.
All stimulus props accept Symbol paths as well as Strings (e.g. `stimulus_controllers: [:custom, :"admin/users"]`). `stimulus_values:` and `stimulus_classes:` additionally accept Array entries (for cross-controller: `[["admin/users", :name, "value"]]`) and pre-built `Vident::Stimulus::Value` / `Vident::Stimulus::ClassMap` instances, so you can compose attribute sets outside the component and pass them in. Anywhere a controller-path String is accepted in a cross-controller slot you may pass the component class (or instance) instead — `[OtherComponent, :name, "value"]`, `stimulus_controllers: [OtherComponent]`, `stimulus_target: [OtherComponent, :row]` — which keeps the reference refactor-safe rather than a hard-coded identifier string.

or you can use tag helpers to generate HTML with Stimulus attributes:

Expand Down
2 changes: 1 addition & 1 deletion UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ ButtonComponent.stimulus_target(:submit).to_h ==
Two constraints at class level:

- **Outlets require an explicit selector.** A class has no `component_id` to auto-scope with, and an unscoped `[data-controller~=foo]` silently matches any sibling. `ButtonComponent.stimulus_outlet(:modal)` raises `Vident::ParseError`.
- **Cross-controller forms are rejected.** `ButtonComponent.stimulus_target("other/ctrl", :row)` reads like "target on the receiver" but silently ignores the receiver's identifier. If you need cross-controller, call the parser directly: `Vident::Stimulus::Target.parse("other/ctrl", :row, implied: ButtonComponent.stimulus_controller)`.
- **The cross-controller String form is rejected.** `ButtonComponent.stimulus_target("other/ctrl", :row)` reads like "target on the receiver" but silently ignores the receiver's identifier. Either call the parser directly`Vident::Stimulus::Target.parse("other/ctrl", :row, implied: ButtonComponent.stimulus_controller)` — or, since 3.1, name the other component explicitly: `ButtonComponent.stimulus_target(OtherComponent, :row)`. A component names its own controller, so nothing is silently ignored. (Outlets keep needing an explicit `Vident::Selector` even with a component.)

---

Expand Down
4 changes: 2 additions & 2 deletions lib/vident/stimulus/action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ def self.parse(*args, implied:, component_id: nil)
method_name: Naming.js_name(method_sym),
event: event.to_s
)
in [String => ctrl_path, Symbol => method_sym]
in [ctrl_path, Symbol => method_sym] if Controller.path_arg?(ctrl_path)
new(
controller: Controller.parse(ctrl_path, implied: implied),
method_name: Naming.js_name(method_sym),
event: nil
)
in [Symbol => event, String => ctrl_path, Symbol => method_sym]
in [Symbol => event, ctrl_path, Symbol => method_sym] if Controller.path_arg?(ctrl_path)
new(
controller: Controller.parse(ctrl_path, implied: implied),
method_name: Naming.js_name(method_sym),
Expand Down
2 changes: 1 addition & 1 deletion lib/vident/stimulus/class_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def self.parse(*args, implied:, component_id: nil)
name: name_sym.to_s.dasherize,
css: normalize_css(css_input)
)
in [String => ctrl_path, Symbol => name_sym, css_input]
in [ctrl_path, Symbol => name_sym, css_input] if Controller.path_arg?(ctrl_path)
new(
controller: Controller.parse(ctrl_path, implied: implied),
name: name_sym.to_s.dasherize,
Expand Down
16 changes: 14 additions & 2 deletions lib/vident/stimulus/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@ def self.parse(*args, implied:, as: nil, component_id: nil)
new(path: implied.path, name: implied.name, alias_name: as)
when 1
raw = args[0]
path = raw.to_s
new(path: path, name: Naming.stimulize_path(path), alias_name: as)
if raw.respond_to?(:stimulus_identifier)
name = raw.stimulus_identifier
path = raw.respond_to?(:stimulus_identifier_path) ? raw.stimulus_identifier_path : name
new(path: path, name: name, alias_name: as)
else
path = raw.to_s
new(path: path, name: Naming.stimulize_path(path), alias_name: as)
end
else
raise ::Vident::ParseError, "Controller.parse: expected 0 or 1 positional args, got #{args.size}"
end
end

# Whether `value` is accepted in a cross-controller path slot: a path
# String, or a Vident component (class or instance) that names itself.
def self.path_arg?(value)
value.is_a?(String) || value.respond_to?(:stimulus_identifier)
end

def identifier = name

def to_s = name
Expand Down
4 changes: 2 additions & 2 deletions lib/vident/stimulus/outlet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ def self.parse(*args, implied:, component_id: nil)
new(controller: implied, name: sym.to_s.dasherize, selector: sel.css)
in [String => str, Selector => sel]
new(controller: implied, name: Naming.stimulize_path(str), selector: sel.css)
in [String => parent_path, Symbol => child_sym]
in [parent_path, Symbol => child_sym] if Controller.path_arg?(parent_path)
child_name = child_sym.to_s.dasherize
new(
controller: Controller.parse(parent_path, implied: implied),
name: child_name,
selector: auto_selector(child_name, component_id: component_id)
)
in [String => parent_path, Symbol => child_sym, Selector => sel]
in [parent_path, Symbol => child_sym, Selector => sel] if Controller.path_arg?(parent_path)
new(
controller: Controller.parse(parent_path, implied: implied),
name: child_sym.to_s.dasherize,
Expand Down
2 changes: 1 addition & 1 deletion lib/vident/stimulus/param.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def self.parse(*args, implied:, component_id: nil)
name: name_sym.to_s.dasherize,
serialized: serialize(raw)
)
in [String => ctrl_path, Symbol => name_sym, raw]
in [ctrl_path, Symbol => name_sym, raw] if Controller.path_arg?(ctrl_path)
new(
controller: Controller.parse(ctrl_path, implied: implied),
name: name_sym.to_s.dasherize,
Expand Down
2 changes: 1 addition & 1 deletion lib/vident/stimulus/target.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def self.parse(*args, implied:, component_id: nil)
case args
in [Symbol => sym]
new(controller: implied, name: Naming.js_name(sym))
in [String => ctrl_path, Symbol => sym]
in [ctrl_path, Symbol => sym] if Controller.path_arg?(ctrl_path)
new(
controller: Controller.parse(ctrl_path, implied: implied),
name: Naming.js_name(sym)
Expand Down
2 changes: 1 addition & 1 deletion lib/vident/stimulus/value.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def self.parse(*args, implied:, component_id: nil)
name: name_sym.to_s.dasherize,
serialized: serialize(raw)
)
in [String => ctrl_path, Symbol => name_sym, raw]
in [ctrl_path, Symbol => name_sym, raw] if Controller.path_arg?(ctrl_path)
new(
controller: Controller.parse(ctrl_path, implied: implied),
name: name_sym.to_s.dasherize,
Expand Down
2 changes: 1 addition & 1 deletion lib/vident/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Vident
VERSION = "3.0.0"
VERSION = "3.1.0"
end
5 changes: 4 additions & 1 deletion skills/vident/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class DropdownComponent < ApplicationComponent
end
```

Cross-controller references elsewhere (actions/targets/values/classes/outlets) use the `"path/to/controller"` **string** form — Vident stimulizes it for you.
Cross-controller references elsewhere (actions/targets/values/classes/outlets) take the controller in one of two forms: a `"path/to/controller"` **string** (Vident stimulizes it for you), or the **component class/instance** itself in that slot — `OtherComponent` in place of `"other_component"`. The component form is refactor-safe: rename the class and the reference follows, with no identifier string to drift.

### 1.2 Actions

Expand Down Expand Up @@ -118,6 +118,7 @@ The alias resolves at render time — unknown aliases raise `Vident::Declaration
| `:my_thing` | `implied#myThing` (no explicit event) |
| `[:click, :my_thing]` | `click->implied#myThing` |
| `[:click, "other/ctrl", :my_thing]` | `click->other--ctrl#myThing` |
| `[:click, OtherCtrl, :my_thing]` | same, via the component class (refactor-safe) |
| `"click->other--ctrl#myThing"` | pass-through, parsed into its parts |
| `{event: :click, method: :submit, options: [:once, :prevent]}` | `click:once:prevent->implied#submit` |
| `-> { [:click, :my_thing] if @editable }` | proc, evaluated in component instance; `nil`/`false` returns drop the entry |
Expand Down Expand Up @@ -179,6 +180,7 @@ Vident `targets` DSL:
| ------------------------------ | -------------------------------------------------- |
| `:button` | `data-implied-target="button"` on the root |
| `["other/ctrl", :row]` | `data-other--ctrl-target="row"` on the root |
| `[OtherCtrl, :row]` | same, via the component class (refactor-safe) |
| Same shapes on `child_element` | `data-implied-target="..."` on the child |

```ruby
Expand Down Expand Up @@ -224,6 +226,7 @@ end
stimulus_values: [
[:foo, "bar"], # implied-foo-value="bar"
["other/ctrl", :baz, 42], # other--ctrl-baz-value="42"
[OtherCtrl, :baz, 42], # same, via the component class
]
```

Expand Down
1 change: 1 addition & 0 deletions test/public_api_spec/phlex_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class PhlexTest < Minitest::Test
include Props
include InstanceParsers
include ChildElement
include CrossController
include StableId
include ClassList
include Caching
Expand Down
99 changes: 99 additions & 0 deletions test/public_api_spec/specs/cross_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

module Vident
module PublicApiSpec
# A parent component emitting Stimulus data attributes bound to a *child*
# component's controller, without hard-coding the child's identifier
# string. Two forms: reusing the child's class-level value objects, and
# naming the child class directly in a parser's controller slot.
module CrossController
def cross_child_class
define_component(name: "RowComponent") do
stimulus do
targets :summary
actions :toggle
values open: false
end
end
end

def cross_parent(child, &body)
klass = define_component(name: "PanelComponent")
if klass.ancestors.include?(::Phlex::HTML)
klass.define_method(:view_template) { instance_exec(child, &body) }
else
klass.define_method(:call) { instance_exec(child, &body) }
end
klass
end

# ---- reusing the child's class-level value objects -----------------

def test_child_class_level_controller_object_reused
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:div, stimulus_controllers: [row.stimulus_controller]) }
end
assert_match(/data-controller="[^"]*\brow-component\b/, render(parent.new))
end

def test_child_class_level_target_object_reused
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:button, stimulus_target: row.stimulus_target(:summary)) }
end
assert_match(/data-row-component-target="summary"/, render(parent.new))
end

def test_child_class_level_action_object_reused
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:button, stimulus_action: row.stimulus_action(:click, :toggle)) }
end
assert_match(/data-action="click->row-component#toggle"/, render(parent.new))
end

def test_child_class_level_value_object_reused
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:div, stimulus_values: [row.stimulus_value(:open, false)]) }
end
assert_match(/data-row-component-open-value="false"/, render(parent.new))
end

# ---- naming the child class directly in a controller slot ----------

def test_child_class_as_controller_in_plural
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:div, stimulus_controllers: [row]) }
end
assert_match(/data-controller="[^"]*\brow-component\b/, render(parent.new))
end

def test_child_class_in_target_tuple
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:button, stimulus_target: [row, :summary]) }
end
assert_match(/data-row-component-target="summary"/, render(parent.new))
end

def test_child_class_in_action_tuple
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:button, stimulus_action: [:click, row, :toggle]) }
end
assert_match(/data-action="click->row-component#toggle"/, render(parent.new))
end

def test_child_class_in_value_tuple
child = cross_child_class
parent = cross_parent(child) do |row|
root_element { child_element(:div, stimulus_values: [[row, :open, false]]) }
end
assert_match(/data-row-component-open-value="false"/, render(parent.new))
end
end
end
end
1 change: 1 addition & 0 deletions test/public_api_spec/view_component_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class ViewComponentTest < ::ViewComponent::TestCase
include Props
include InstanceParsers
include ChildElement
include CrossController
include StableId
include ClassList
include Caching
Expand Down
54 changes: 54 additions & 0 deletions test/vident/class_level_stimulus_builders_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,60 @@ def test_stimulus_outlet_without_selector_error_message_is_helpful
assert_match(/Selector/, err.message)
end

# ---- cross-controller via a component class -------------------------
#
# The String cross-controller form raises at class level (above) because
# the receiver's implied controller would be silently ignored. A component
# names its own controller explicitly, so there is nothing ambiguous to
# ignore — these forms are accepted and bind to the component's controller.

def test_stimulus_target_accepts_component_in_cross_controller_slot
row = make_component(name: "RowComponent")
t = make_component(name: "PanelComponent").stimulus_target(row, :summary)
assert_equal "row-component", t.controller.name
assert_equal "summary", t.name
end

def test_stimulus_action_accepts_component_without_event
row = make_component(name: "RowComponent")
a = make_component(name: "PanelComponent").stimulus_action(row, :toggle)
assert_equal "row-component#toggle", a.to_s
end

def test_stimulus_action_accepts_component_with_event
row = make_component(name: "RowComponent")
a = make_component(name: "PanelComponent").stimulus_action(:click, row, :toggle)
assert_equal "click->row-component#toggle", a.to_s
end

def test_stimulus_value_accepts_component
row = make_component(name: "RowComponent")
v = make_component(name: "PanelComponent").stimulus_value(row, :open, false)
assert_equal "row-component", v.controller.name
assert_equal({"row-component-open-value": "false"}, v.to_h)
end

def test_stimulus_param_accepts_component
row = make_component(name: "RowComponent")
p = make_component(name: "PanelComponent").stimulus_param(row, :row_id, 7)
assert_equal "row-component", p.controller.name
end

def test_stimulus_class_accepts_component
row = make_component(name: "RowComponent")
cm = make_component(name: "PanelComponent").stimulus_class(row, :busy, "opacity-50")
assert_equal "row-component", cm.controller.name
end

# Outlet keeps its class-level restriction: a Selector is still required,
# so the component shortcut alone raises rather than auto-scoping.
def test_stimulus_outlet_component_without_selector_still_raises
row = make_component(name: "RowComponent")
assert_raises(::Vident::ParseError) do
make_component(name: "PanelComponent").stimulus_outlet(row, :item)
end
end

# ---- Inheritance: subclass uses its own implied controller ----------

def test_subclass_target_uses_child_implied_controller
Expand Down
Loading
Loading