phlex-stimulus 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9daeb0661e299cb12e012622f2fd879e1b0a5b0cb47f35f10fa0d7a306ef1f1
4
- data.tar.gz: e104306fddb134fd3aab3e8c3e987b132e209b4f59ce0196d711cb5b0ec19539
3
+ metadata.gz: f7130b1b0657eb83abc485ee417ef0df2f26179c78a8eb4dbdd507e94f42350d
4
+ data.tar.gz: 4edb623ff5c547ed266a26c8bd3f612d0ecd86869f102c851dda10c26fca6f0d
5
5
  SHA512:
6
- metadata.gz: d89cc17fb9c9d6178adf2a52c5648a2cfce4b338b0b0e703d0ed62186958c1883e35c2597010cc584fc3f63dda930890a7c00b3215f984489bd3524f726182ee
7
- data.tar.gz: 4ef495c9749504edff2fb5fd93e8027ba2ca9a739872078a66b52dbaea6880132101d905c481a594b1245ed1f168d9f5ddf9dd4b4c8d4058b5f3c8206e65faf4
6
+ metadata.gz: a94440b690254ad5a75deb5b6c4b9b6a7ba3bf069ce66e6448ca478b463e2b48739f70f479a7f224fc53d79104ec62f3802417de5e24498ca3484a108687dbb5
7
+ data.tar.gz: d3888457777dc1807b7c9d437a82c7086a0a5d9a098a5d397304754a4e07b64f75e3b47948a5410fd973e0d39a682953d4e94bcc55944deceaa4d5cb7e1c4edb
data/CHANGELOG.md CHANGED
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  Add changes in new features here. Do not change the gem's version in pull/merge requests.
11
11
 
12
+ ## [0.3.0] - 18.08.2026
13
+
14
+ [Diff](https://github.com/espago/phlex-stimulus/compare/v0.2.0...v0.3.0)
15
+
16
+ - Expand action parameter support
17
+ - Add `Components::Controller#merge` method for easily merging hashes
18
+ - Add a new action parameter definition DSL
19
+ - Add `_target_anchor` helpers to make attaching controller targets to DOM elements easier
20
+ - Add action `_on` helpers to attach controller actions to DOM event easier and more typesafe with action parameters
21
+
22
+ ## [0.2.0] - 29.07.2026
23
+
24
+ [Diff](https://github.com/espago/phlex-stimulus/compare/v0.1.3...v0.2.0)
25
+
26
+ - Add action parameter support
27
+
12
28
  ## [0.1.3] - 28.07.2026
13
29
 
14
30
  [Diff](https://github.com/espago/phlex-stimulus/compare/v0.1.2...v0.1.3)
data/README.md CHANGED
@@ -58,6 +58,10 @@ module Components
58
58
  self.controller_name = 'summary'
59
59
 
60
60
  actions :redirect # => defines SummaryController.redirect_action #=> "summary#redirect"
61
+ action :loadItem do # defines an action with action parameters
62
+ param :id # required action parameter
63
+ param :async, optional: true # optional action parameter
64
+ end
61
65
  targets :body # => defines SummaryController.body_target #=> "body"
62
66
 
63
67
  #: (title: String) -> void
@@ -81,11 +85,31 @@ You can render a controller like any other Phlex component:
81
85
 
82
86
  ```ruby
83
87
  render SummaryController.new(title: 'Report') do
84
- # define an action
88
+ # define an action with parameters
89
+ button(data: merge(SummaryController.load_item_on('click', id: '25'), other: 'data')) { 'Go' }
90
+
91
+ # define an action without param checking
85
92
  button(data: { action: event('click', SummaryController.redirect_action) }) { 'Go' }
86
93
 
87
94
  # define a target
88
- div(data: { SummaryController.target_key => SummaryController.body_target }) do
95
+ div(data: merge(SummaryController.body_target_anchor, other: "data")) do
96
+ plan "Foo"
97
+ end
98
+ end
99
+ ```
100
+
101
+ This is the same as:
102
+
103
+ ```rb
104
+ div(data: { controller: 'summary', title: 'Report' }) do
105
+ # define an action with parameters
106
+ button(data: { action: 'click->summary#loadItem', 'summary-id-param' => '25', other: 'data' }) { 'Go' }
107
+
108
+ # define an action
109
+ button(data: { action: 'click->summary#redirect', other: 'data' }) { 'Go' }
110
+
111
+ # define a target
112
+ div(data: { 'summary-target' => 'body', other: 'data' }) do
89
113
  plan "Foo"
90
114
  end
91
115
  end
@@ -93,6 +117,47 @@ end
93
117
 
94
118
  ### Controller component helpers
95
119
 
120
+ #### `_on`
121
+
122
+ Each action you define will result in a corresponding `_on` method being available in Ruby.
123
+ Sorbet is fully aware of these methods thanks to a tapioca compiler.
124
+
125
+ These methods return an anchor hash that can be used as a value in `data:` to attach the
126
+ action with parameters to a DOM event on a particular DOM element.
127
+
128
+ You can read more about actions [here](https://stimulus.hotwired.dev/reference/actions).
129
+
130
+ ```rb
131
+ class SummaryController < Controller
132
+ self.controller_name = 'summary'
133
+
134
+ actions :exit
135
+
136
+ action :loadItem do
137
+ param :id
138
+ param :async, optional: true
139
+ end
140
+ end
141
+
142
+ SummaryController.exit_on('click') #=> { action: "click->summary#exit" }
143
+ SummaryController.load_item_on('click', id: "25") #=> { action: "click->summary#loadItem", "summary-id-param" => "25" }
144
+ SummaryController.load_item_on('click', id: "25", async: "true") #=> { action: "click->summary#loadItem", "summary-id-param" => "25", "summary-async-param" => "true" }
145
+ ```
146
+
147
+ This can be used to attach an action to an HTML element with full type safety including action params.
148
+
149
+ ```rb
150
+ div(data: SummaryController.exit_on('click'))
151
+ div(data: merge(SummaryController.load_item_on('click', id: "69"), other: "data"))
152
+ ```
153
+
154
+ This is the same as:
155
+
156
+ ```rb
157
+ div(data: { action: 'click->summary#exit' })
158
+ div(data: { action: 'click->summary#loadItem', 'summary-id-param' => '69', other: 'data' })
159
+ ```
160
+
96
161
  #### `_action`
97
162
 
98
163
  Each action you define will result in a corresponding `_action` method being available in Ruby.
@@ -126,14 +191,12 @@ This is the same as:
126
191
  div(data: { action: 'click->summary#foo' })
127
192
  ```
128
193
 
129
- #### `_target`
194
+ #### `_target_anchor`
130
195
 
131
- Each target you define will result in a corresponding `_target` method being available in Ruby.
196
+ Each target you define will result in a corresponding `_target_anchor` method being available in Ruby.
132
197
  Sorbet is fully aware of these methods thanks to a tapioca compiler.
133
198
 
134
- These methods just return the name of the target so they may seem pointless.
135
- They exist only so that sorbet can check if the actions your using when building HTML elements
136
- actually exist.
199
+ These methods return a hash that can be used to attach the target.
137
200
 
138
201
  You can read more about targets [here](https://stimulus.hotwired.dev/reference/targets).
139
202
 
@@ -144,13 +207,13 @@ class SummaryController < Controller
144
207
  targets :foo
145
208
  end
146
209
 
147
- SummaryController.foo_target #=> "foo"
210
+ SummaryController.foo_target_anchor #=> { "summary-target" => "foo" }
148
211
  ```
149
212
 
150
213
  You would use it like so to attach a target with full type safety:
151
214
 
152
215
  ```rb
153
- div(data: { SummaryController.target_key => SummaryController.foo_target })
216
+ div(data: SummaryController.foo_target_anchor)
154
217
  ```
155
218
 
156
219
  This is the same as:
@@ -159,6 +222,19 @@ This is the same as:
159
222
  div(data: { 'summary-target' => 'foo' })
160
223
  ```
161
224
 
225
+ You can use `merge` to add other keys alongside the anchor to `data`.
226
+
227
+ ```rb
228
+ div(data: merge(SummaryController.foo_target_anchor, bar: 'elo'))
229
+ ```
230
+
231
+ This is the same as:
232
+
233
+ ```rb
234
+ div(data: { 'summary-target' => 'foo', bar: 'elo' })
235
+ ```
236
+
237
+
162
238
  #### `target_key`
163
239
 
164
240
  This method returns the key that can be used to attach targets to the controller.
@@ -185,6 +261,39 @@ This is the same as:
185
261
  div(data: { 'summary-target' => 'foo' })
186
262
  ```
187
263
 
264
+ #### `_target`
265
+
266
+ Each target you define will result in a corresponding `_target` method being available in Ruby.
267
+ Sorbet is fully aware of these methods thanks to a tapioca compiler.
268
+
269
+ These methods just return the name of the target so they may seem pointless.
270
+ They exist only so that sorbet can check if the actions your using when building HTML elements
271
+ actually exist.
272
+
273
+ You can read more about targets [here](https://stimulus.hotwired.dev/reference/targets).
274
+
275
+ ```rb
276
+ class SummaryController < Controller
277
+ self.controller_name = 'summary'
278
+
279
+ targets :foo
280
+ end
281
+
282
+ SummaryController.foo_target #=> "foo"
283
+ ```
284
+
285
+ You would use it like so to attach a target with full type safety:
286
+
287
+ ```rb
288
+ div(data: { SummaryController.target_key => SummaryController.foo_target })
289
+ ```
290
+
291
+ This is the same as:
292
+
293
+ ```rb
294
+ div(data: { 'summary-target' => 'foo' })
295
+ ```
296
+
188
297
  #### `dispatched`
189
298
 
190
299
  This method helps you get the names of custom events dispatched by a stimulus controller.
@@ -207,7 +316,46 @@ div(data: { action: event(SummaryController.dispatched('redirected'), OtherContr
207
316
  This is the same as:
208
317
 
209
318
  ```rb
210
- div(data: { 'summary:redirected' => 'other#do' })
319
+ div(data: { action: 'summary:redirected->other#do' })
320
+ ```
321
+
322
+ #### `param`
323
+
324
+ This method helps you get the names of parameters
325
+ given to stimulus actions.
326
+ You can read more about action parameters [here](https://stimulus.hotwired.dev/reference/https://stimulus.hotwired.dev/reference/actions#action-parameters).
327
+
328
+ ```rb
329
+ class SummaryController < Controller
330
+ self.controller_name = 'summary'
331
+
332
+ actions :foo
333
+ end
334
+
335
+ SummaryController.param('id') #=> "summary-id-param"
336
+ ```
337
+
338
+ You would use it like so to define a parameter for a stimulus action:
339
+
340
+ ```rb
341
+ div(
342
+ data: {
343
+ action: event('click', SummaryController.foo_action),
344
+ # will be available as `event.id` in the action
345
+ SummaryController.param('id') => '35',
346
+ },
347
+ )
348
+ ```
349
+
350
+ This is the same as:
351
+
352
+ ```rb
353
+ div(
354
+ data: {
355
+ action: 'click->summary#foo',
356
+ 'summary-id-param' => '35',
357
+ },
358
+ )
211
359
  ```
212
360
 
213
361
  ### Component Helpers
@@ -44,6 +44,7 @@ module Phlex::Stimulus::Generators
44
44
  remove_file File.join(destination_root, 'app/javascript/application.js')
45
45
  copy_template 'app/javascript/application.ts'
46
46
 
47
+ remove_file File.join(destination_root, 'app/javascript/controllers/hello_controller.js')
47
48
  remove_file File.join(destination_root, 'app/javascript/controllers/application.js')
48
49
  copy_template 'app/javascript/controllers/application.ts'
49
50
 
@@ -10,6 +10,7 @@ import { Controller } from "@hotwired/stimulus"
10
10
  **/
11
11
  class ChainController extends Controller<HTMLElement> {
12
12
  controllerChain!: string
13
+ disconnected: boolean | undefined
13
14
 
14
15
  async connect() {
15
16
  let dataset = this.element.dataset
@@ -17,6 +18,10 @@ class ChainController extends Controller<HTMLElement> {
17
18
 
18
19
  let i = 0
19
20
  for (let controllerName of this.controllerChain.split(' ')) {
21
+ if (this.disconnected) {
22
+ return
23
+ }
24
+
20
25
  let controllerTemplate = this.element.querySelector(`template.${controllerName}-template-${i}`) as HTMLTemplateElement
21
26
  let controllerElement = document.importNode(controllerTemplate.content, true)
22
27
  let readyPromise = addEventPromise(this.element, `${controllerName}:ready`)
@@ -27,6 +32,10 @@ class ChainController extends Controller<HTMLElement> {
27
32
  i++
28
33
  }
29
34
  }
35
+
36
+ disconnect(): void {
37
+ this.disconnected = true
38
+ }
30
39
  }
31
40
 
32
41
  application.register("chain", ChainController)
@@ -32,6 +32,15 @@ export function sleep(timeout: number | undefined): Promise<void> {
32
32
  */
33
33
  export function addEventPromise(target: EventTarget, type: string): Promise<Event> {
34
34
  return new Promise((resolve) => {
35
- target.addEventListener(type, (event) => resolve(event))
35
+ const aborter = new AbortController()
36
+
37
+ target.addEventListener(
38
+ type,
39
+ (event) => {
40
+ aborter.abort()
41
+ resolve(event)
42
+ },
43
+ { signal: aborter.signal },
44
+ )
36
45
  })
37
46
  }
@@ -7,6 +7,18 @@ module Phlex::Stimulus
7
7
  #
8
8
  # @abstract
9
9
  class Base < Phlex::HTML
10
+ class << self
11
+ # Constructs a Stimulus event hook string
12
+ # eg.
13
+ #
14
+ # event('click', 'summary#redirect') #=> "click->summary#redirect"
15
+ #
16
+ #: (String, String) -> String
17
+ def event(event_name, full_action_name)
18
+ "#{event_name}->#{full_action_name}"
19
+ end
20
+ end
21
+
10
22
  def h = view_context
11
23
 
12
24
  #: -> String
@@ -70,6 +82,16 @@ module Phlex::Stimulus
70
82
  alias strlist class_list
71
83
  alias strlist! class_list!
72
84
 
85
+ # Merges the given list of hashes (with optional nils)
86
+ # into a single hash.
87
+ #
88
+ #: [K, V] (*Hash[K, V]?) -> Hash[K, V]
89
+ def merge(*hashes)
90
+ hashes.each_with_object({}) do |elem, acc|
91
+ acc.merge!(elem) if elem
92
+ end
93
+ end
94
+
73
95
  #: (String) -> bool
74
96
  def image_exists?(path)
75
97
  Boolean(h.resolve_asset_path(path))
@@ -13,11 +13,14 @@ module Phlex::Stimulus
13
13
  attr_reader :component
14
14
  #: String
15
15
  attr_reader :action_name
16
+ #: Array[ActionParamDefinition]
17
+ attr_reader :params
16
18
 
17
- #: (component: singleton(Controller), action_name: String) -> void
18
- def initialize(component:, action_name:)
19
+ #: (component: singleton(Controller), action_name: String, ?params: Array[ActionParamDefinition]) -> void
20
+ def initialize(component:, action_name:, params: [])
19
21
  @component = component
20
22
  @action_name = action_name
23
+ @params = params
21
24
  end
22
25
 
23
26
  #: -> String
@@ -34,6 +37,36 @@ module Phlex::Stimulus
34
37
  def ruby_action_method_name
35
38
  "#{ruby_name}_action"
36
39
  end
40
+
41
+ #: -> String
42
+ def ruby_on_method_name
43
+ "#{ruby_name}_on"
44
+ end
45
+
46
+ # Define a new parameter for the controller action.
47
+ #
48
+ #: (Symbol, ?optional: bool) -> ActionParamDefinition
49
+ def param(name, optional: false)
50
+ p = ActionParamDefinition.new(action: self, param_name: name.to_s, optional: optional)
51
+ params << p
52
+ p
53
+ end
54
+ end
55
+
56
+ class ActionParamDefinition
57
+ #: ActionDefinition
58
+ attr_reader :action
59
+ #: String
60
+ attr_reader :param_name
61
+ #: bool
62
+ attr_reader :optional
63
+
64
+ #: (action: ActionDefinition, param_name: String, ?optional: bool) -> void
65
+ def initialize(action:, param_name:, optional: false)
66
+ @action = action
67
+ @param_name = param_name
68
+ @optional = optional
69
+ end
37
70
  end
38
71
 
39
72
  class TargetDefinition
@@ -57,6 +90,11 @@ module Phlex::Stimulus
57
90
  def ruby_target_method_name
58
91
  "#{ruby_name}_target"
59
92
  end
93
+
94
+ #: -> String
95
+ def ruby_target_anchor_method_name
96
+ "#{ruby_name}_target_anchor"
97
+ end
60
98
  end
61
99
 
62
100
  class << self
@@ -84,6 +122,16 @@ module Phlex::Stimulus
84
122
  @target_defs ||= []
85
123
  end
86
124
 
125
+ # Constructs a Stimulus action parameter name
126
+ # eg.
127
+ #
128
+ # SummaryController.param('id') #=> "summary-id-param"
129
+ #
130
+ #: (String) -> String
131
+ def param(param_name)
132
+ "#{controller_name}-#{param_name}-param"
133
+ end
134
+
87
135
  # Constructs a Stimulus dispatched event name
88
136
  # eg.
89
137
  #
@@ -110,9 +158,51 @@ module Phlex::Stimulus
110
158
  "data-#{target_key}"
111
159
  end
112
160
 
161
+ # Register an action with parameters that exists on the Stimulus controller.
162
+ #
163
+ # action :foo do
164
+ # param :id
165
+ # param :title, optional: true
166
+ # end
167
+ #
168
+ #: (Symbol) ?{ [self: ActionDefinition] -> void } -> void
169
+ def action(name, &block)
170
+ action_def = ActionDefinition.new(
171
+ component: self,
172
+ action_name: name.to_s,
173
+ )
174
+ action_defs << action_def
175
+ action_def.instance_eval(&block) if block
176
+
177
+ eval_buff = String.new
178
+ eval_buff << <<~RUBY
179
+ def #{action_def.ruby_action_method_name}
180
+ "\#{controller_name}##{name}"
181
+ end
182
+ RUBY
183
+
184
+ eval_buff << "\ndef #{action_def.ruby_on_method_name}(event_name"
185
+ action_def.params.each do |param|
186
+ eval_buff << ", #{param.param_name}:"
187
+ eval_buff << ' nil' if param.optional
188
+ end
189
+ eval_buff << ")\n"
190
+
191
+ eval_buff << "{ action: event(event_name, #{action_def.ruby_action_method_name}), "
192
+ action_def.params.each do |param|
193
+ eval_buff << "param(#{param.param_name.inspect}) => #{param.param_name}, "
194
+ end
195
+ eval_buff << "}.compact\n"
196
+ eval_buff << "end\n"
197
+
198
+ instance_eval eval_buff, __FILE__, __LINE__
199
+ end
200
+
113
201
  # Register actions that exist on the Stimulus controller.
114
202
  # Will define typed getter methods for each action.
115
203
  #
204
+ # actions :foo, :bar
205
+ #
116
206
  #: (*Symbol) -> void
117
207
  def actions(*actions)
118
208
  actions.each do |action|
@@ -126,6 +216,10 @@ module Phlex::Stimulus
126
216
  def #{action_def.ruby_action_method_name}
127
217
  "\#{controller_name}##{action}"
128
218
  end
219
+
220
+ def #{action_def.ruby_on_method_name}(event_name)
221
+ { action: event(event_name, #{action_def.ruby_action_method_name}) }
222
+ end
129
223
  RUBY
130
224
  end
131
225
  end
@@ -147,6 +241,10 @@ module Phlex::Stimulus
147
241
  def #{target_def.ruby_target_method_name}
148
242
  #{target_str.inspect}
149
243
  end
244
+
245
+ def #{target_def.ruby_target_anchor_method_name}
246
+ { target_key => #{target_str.inspect} }
247
+ end
150
248
  RUBY
151
249
  end
152
250
  end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Phlex
5
5
  module Stimulus
6
- VERSION = '0.1.3'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
@@ -50,6 +50,42 @@ module Tapioca
50
50
  return_type: 'String',
51
51
  comments: comments,
52
52
  )
53
+
54
+ comments = [
55
+ RBI::Comment.new(
56
+ <<~DOC,
57
+ Returns na anchor hash that can be used to attach `#{action.action_name}`
58
+ action on `#{action.component.controller_name.inspect}` Stimulus controller
59
+ to a particular DOM event.
60
+ DOC
61
+ ),
62
+ ]
63
+ mod.create_method(
64
+ action.ruby_on_method_name,
65
+ comments: comments,
66
+ ) do |method|
67
+ method.add_param('event_name')
68
+ action.params.each do |param|
69
+ if param.optional
70
+ method.add_kw_opt_param(param.param_name, 'nil')
71
+ else
72
+ method.add_kw_param(param.param_name)
73
+ end
74
+ end
75
+
76
+ method.add_sig do |sig|
77
+ sig.return_type = 'T::Hash[T.any(Symbol, String), String]'
78
+
79
+ sig.add_param('event_name', 'String')
80
+ action.params.each do |param|
81
+ if param.optional
82
+ sig.add_param(param.param_name, 'T.nilable(String)')
83
+ else
84
+ sig.add_param(param.param_name, 'String')
85
+ end
86
+ end
87
+ end
88
+ end
53
89
  end
54
90
 
55
91
  targets = constant.target_defs.sort_by(&:target_name)
@@ -69,6 +105,23 @@ module Tapioca
69
105
  return_type: 'String',
70
106
  comments: comments,
71
107
  )
108
+
109
+ comments = [
110
+ RBI::Comment.new(
111
+ <<~DOC,
112
+ Returns an anchor hash for `#{target.target_name}`
113
+ target on `#{target.component.controller_name.inspect}` Stimulus controller
114
+ that can be used to attach this target to a particular DOM element.
115
+
116
+ Result: `{ #{constant.target_key} => #{target.target_name.inspect} }`
117
+ DOC
118
+ ),
119
+ ]
120
+ mod.create_method(
121
+ target.ruby_target_anchor_method_name,
122
+ return_type: 'T::Hash[String, String]',
123
+ comments: comments,
124
+ )
72
125
  end
73
126
 
74
127
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-stimulus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Espago