phlex-stimulus pairs every Stimulus controller in your app with a
Phlex component, and keeps both sides strictly typed.
For each Stimulus controller (the TypeScript side) there is a matching Phlex component
(the Ruby side). The component owns the markup that mounts the controller — the data-controller
attribute, its targets, actions, and values — and exposes strictly Sorbet-typed
helpers so the wiring between Ruby and JS can't silently drift.
Controllers can also be chained: a set of controllers can be rendered so that each one's connect
logic runs to completion before the next one connects.
Install the gem and add to the application's Gemfile by executing:
bundle add phlex-stimulusIf bundler is not being used to manage dependencies, install the gem by executing:
gem install phlex-stimulusThen run the installer.
bundle exec rails generate phlex:stimulus:installbundle exec rails generate phlex:stimulus:controller SummaryThis creates a matched pair:
app/components/summary_controller.rb— aComponents::SummaryControllerapp/javascript/controllers/summary_controller.ts— aSummaryController
A controller component declares the Stimulus controller name, its actions, and its targets. Those declarations generate strictly-typed helpers so your markup references controllers, actions, and targets by method call instead of by hand-written string:
# typed: strict
module Components
class SummaryController < Controller
self.controller_name = 'summary'
actions :redirect # => defines SummaryController.redirect_action #=> "summary#redirect"
targets :body # => defines SummaryController.body_target #=> "body"
#: (title: String) -> void
def initialize(title:)
@title = title
end
# @override
#: ?{ -> void } -> void
def view_template(&block)
div(data: { controller: self.class.controller_name, title: @title }) do
block&.call
end
end
end
endYou can render a controller like any other Phlex component:
render SummaryController.new(title: 'Report') do
# define an action
button(data: { action: event('click', SummaryController.redirect_action) }) { 'Go' }
# define a target
div(data: { SummaryController.target_key => SummaryController.body_target }) do
plan "Foo"
end
endEach action you define will result in a corresponding _action method being available in Ruby.
Sorbet is fully aware of these methods thanks to a tapioca compiler.
These methods just return the name of the controller together with the name of the action: "controller#action".
They mostly exist so that sorbet can check if the actions your using when building HTML elements
actually exist.
You can read more about actions here.
class SummaryController < Controller
self.controller_name = 'summary'
actions :foo
end
SummaryController.foo_action #=> "summary#foo"This can be used to attach an action to an HTML element with full type safety.
div(data: { action: event('click', SummaryController.foo_action) })This is the same as:
div(data: { action: 'click->summary#foo' })Each target you define will result in a corresponding _target method being available in Ruby.
Sorbet is fully aware of these methods thanks to a tapioca compiler.
These methods just return the name of the target so they may seem pointless. They exist only so that sorbet can check if the actions your using when building HTML elements actually exist.
You can read more about targets here.
class SummaryController < Controller
self.controller_name = 'summary'
targets :foo
end
SummaryController.foo_target #=> "foo"You would use it like so to attach a target with full type safety:
div(data: { SummaryController.target_key => SummaryController.foo_target })This is the same as:
div(data: { 'summary-target' => 'foo' })This method returns the key that can be used to attach targets to the controller.
class SummaryController < Controller
self.controller_name = 'summary'
targets :foo
end
SummaryController.target_key #=> "summary-target"You would use it like so to attach a target with full type safety:
div(data: { SummaryController.target_key => SummaryController.foo_target })This is the same as:
div(data: { 'summary-target' => 'foo' })This method helps you get the names of custom events dispatched by a stimulus controller. You can read more about event dispatching here.
class SummaryController < Controller
self.controller_name = 'summary'
end
SummaryController.dispatched('redirected') #=> "summary:redirected"You would use it like so to define an action on an dispatched event:
div(data: { action: event(SummaryController.dispatched('redirected'), OtherController.do_action) })This is the same as:
div(data: { 'summary:redirected' => 'other#do' })There are some useful helper methods you can use in every phlex component.
attrbool converts a Ruby boolean to 'true' or nil so the boolean value
can be used in attributes of HTML elements
attrbool(true) #=> "true"
attrbool(false) #=> nilYou would use it when passing a boolean value from Ruby to an HTML attribute.
button(data: { enabled: attrbool(@enabled) })event let's you easily and safely create stimulus action strings.
event('click', 'summary#redirect') #=> "click->summary#redirect"
event('click', SummaryController.redirect_action) #=> "click->summary#redirect"You would use it when attaching a controller action to some HTML element.
button(data: { action: event('click', SummaryController.redirect_action) })class_list let's you easily add classes to html elements conditionally.
class_list("foo", "bar", "baz") #=> "foo bar baz"
error = false
class_list("foo", "red" if error, "bar") #=> "foo bar"
error = true
class_list("foo", "red" if error, "bar") #=> "foo red bar"You would use it when declaring an HTML element.
div(class: class_list("foo", "red" if error, "bar")) do
plain "Hello!"
endclass_list is aliased as strlist as it may be useful for other things than CSS classes.
There is a TypedController factory that let's you define strict types for controller targets.
import { application } from "./application"
import { TypedController } from "./typed_controller"
class SummaryController extends TypedController<HTMLElement, { body: HTMLDivElement }>() {
static targets = ["body"]
redirect() {
this.bodyTarget.innerText = "…" // fully typed
}
}
application.register("summary", SummaryController)Sometimes controllers must connect in a specific order — each one finishing its setup before the next
starts. phlex-stimulus supports this on both sides.
On the TypeScript side, a chainable controller extends ChainableController (or
TypedChainableController for typed targets) and puts its connect logic in init(). When init()
resolves, the controller dispatches a ready event that the chain waits on:
class LogController extends ChainableController<HTMLElement> {
async init() {
console.log("connected")
}
}On the Ruby side, render your controller components through Components::ControllerChain. It renders
each controller inside a deferred <template> and drives them via the chain controller so they attach
one after another:
render Components::ControllerChain.new(
Components::SleepController.new(timeout: 500),
Components::LogController.new(message: "ready!"),
)This would result in a "ready!" console message getting displayed after 500 milliseconds.
Two of the bundled controllers are especially useful as chain building blocks:
Components::SleepController.new(timeout:)— pauses the chain for the given number of milliseconds.Components::LogController.new(message:, on_event:)—console.logs a message when it connects (handy for debugging a chain).
You can look those up as examples to see how you can implement your own chainable controllers.
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/espago/phlex-stimulus. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.