Stimulus
Poetry's behavior is Stimulus, so yours composes with it: attach a controller to any component, react to what a component does, drive it from your markup, declare the wiring in Ruby, or extend a controller in JavaScript. Every rung is checked.
The model
Each component declares its Stimulus wiring in Ruby through
use_stimulus, per element: which controllers register
on the root, which targets, values, and actions each part carries.
The rendered attributes are that declaration, validated against
the controllers manifest before the first render. You never write
a Poetry identifier to make a component work; you write your own
controllers next to Poetry's, and the two never clobber each other.
1. Attach your controller to a component
Every helper and every slot setter accepts data: (with
class:, id:, and aria:), and the
root merges it through Poetry's attribute plumbing: Stimulus keys
concatenate instead of replacing, classes merge, nested hashes
flatten. Your controller lands on the same element as Poetry's,
your values ride along, your action sits next to theirs.
<%= poetry_dialog(data: { controller: "cart", action: "keydown.esc->cart#log", cart_sku_value: "A1" }) do |dialog| %>
<% dialog.with_trigger(variant: :outline, data: { action: "click->cart#track" }) { "Edit profile" } %>
<% dialog.with_title { "Edit profile" } %>
Profile form goes here.
<% end %>
<div data-component="dialog" data-controller="cart poetry--core--dialog"
data-action="keydown.esc->cart#log" data-cart-sku-value="A1" ...>
<button data-action="poetry--core--dialog#open click->cart#track" ...>Edit profile</button>
...
A wrapper works too: a <div data-controller="cart">
around the component puts everything inside it in your controller's
scope, which is plain Stimulus.
2. React to what a component does
Forty-two of the fifty-two controllers dispatch namespaced events
(poetry:tabs:change, poetry:combobox:select,
poetry:calendar:change, poetry:clipboard-text:copied,
and so on; each component page lists its own). Listen on the root
or on any ancestor. Overlay content that portals to
<body> while open still reaches listeners at the
original position, because the portal bridge re-dispatches every
declared event there.
<%# on the component root %>
<%= poetry_tabs(..., data: { controller: "analytics", action: "poetry:tabs:change->analytics#track" }) do |tabs| %>
...
<% end %>
<%# or on any ancestor: events bubble, and overlay content that portals
to <body> still reaches this element through the portal bridge %>
<div data-controller="analytics" data-action="poetry:combobox:select->analytics#pick">
<%= poetry_combobox(name: "sku", ...) %>
</div>
Dialog is one of the few that dispatch nothing of their own: its
state is the data-open attribute, and the native
<dialog> cancel and close
events fire as usual.
3. Drive a component from your markup
Any element inside a component's scope can call the component's actions, the same way the component's own parts do. A Cancel button inside a dialog closes it with one descriptor; there is no JavaScript to write. From outside the scope, use the trigger slot, or the WebMCP tool surface for agents.
<%= poetry_dialog do |dialog| %>
<% dialog.with_trigger(variant: :destructive) { "Delete" } %>
<% dialog.with_title { "Delete this project?" } %>
<%= form_with url: project_path(@project), method: :delete do |form| %>
<%= poetry_button(variant: :ghost, type: :button,
data: { action: "click->poetry--core--dialog#close" }) { "Cancel" } %>
<%= poetry_button(variant: :destructive) { "Delete" } %>
<% end %>
<% end %>
The identifier, the action, the targets, and the typed values you
write are all validated by poetry:check, whether they
sit in an attribute or in a helper's data: keywords,
with a did-you-mean when a name is off by a letter.
4. Declare it in Ruby
When the wiring is part of a component's contract rather than
something to repeat at every call site, subclass the component and
declare it with the same DSL Poetry uses. Symbols resolve against
Poetry's manifest and are validated at class load; a String is your
own identifier and passes through verbatim. extend: true
appends to the inherited element; redeclaring an element without it
replaces the element wholesale, which is how Sheet and Drawer put
their own controllers on Dialog's root.
# app/components/cart_dialog.rb
class CartDialog < Poetry::Ui::Dialog::Component
option :sku, :string
use_stimulus do
on :root, extend: true do # extend: append to Dialog's own root wiring
controller "cart" do # a String is your identifier, verbatim
register # data-controller="poetry--core--dialog cart"
action :log, on: "keydown.esc"
value :sku # data-cart-sku-value from the option
end
end
end
end
For wiring too dynamic to declare, stimulus_attributes
hands you one builder per controller, all sharing a single
attributes instance, so multi-controller merges are correct by
construction.
# inside a component: one Attributes instance shared by every builder
stimulus_attributes("cart") do |cart|
cart.register_controller
cart.with_action(:log, on: "keydown.esc")
cart.with_target(:row)
cart.with_value(:sku, sku)
end
# => { "data-controller" => "cart", "data-action" => "keydown.esc->cart#log",
# "data-cart-target" => "row", "data-cart-sku-value" => "A1" }
5. Change what a Poetry controller does
Every Poetry controller is importable by its specifier, so a subclass can override or wrap its methods. Register the subclass under the same identifier after Poetry's registration and Stimulus unloads the earlier definition: your class takes over everywhere that identifier appears, and the registration guard still sees a registered controller.
// app/javascript/controllers/index.js
import { application } from "controllers/application"
import { registerPoetryControllers } from "@poetry/controllers"
import DialogController from "@poetry/controllers/dialog_controller"
class CartDialogController extends DialogController {
open() {
super.open()
this.dispatch("opened", { prefix: "cart" }) // cart:opened
}
}
registerPoetryControllers(application)
application.register("poetry--core--dialog", CartDialogController) // the later registration wins
Two things to know before you reach for this rung. The manifest
still describes the base controller, so a new action name on the
same identifier reads as unknown to poetry:check; put
new behavior in a controller of your own and compose it instead.
And you own the drift: an upgrade that changes the base's
internals changes your subclass's ground. Rungs one to four express
nearly everything; this one is for the rest.
Checked at every rung
poetry:check validates every reference to a Poetry
controller statically. Stimulus itself never errors on an
identifier nothing registered (the element just stays inert), and
one failed import in your controllers graph silently takes every
Poetry controller down with it, so the registrar warns once per
unregistered identifier in the browser console, and the
system-test helpers
ship the same guard as an assertion. Your editor checks your
controllers; poetry:editor lists Poetry's for the
Stimulus language server
so a hand-written poetry--core--dialog#open is never
flagged as unknown.
bin/rails poetry:check # identifiers, actions, targets, typed values - attributes and data: keywords alike
bin/rails g poetry:editor # .stimulus-lsp/config.json: the editor keeps checking YOUR controllers
class CheckoutTest < ApplicationSystemTestCase
include Poetry::Ui::Testing
test "every Poetry controller on the page is registered" do
visit checkout_path
assert_poetry_controllers_registered
end
end