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.
Every controller's purpose, values, actions and events are on the
@poetry/controllers
reference page.
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.
An action you add to a composed trigger (Collapsible, Popover,
Tooltip, DropdownMenu: with_trigger(data: { action: ... }))
runs after the component's own. Stimulus runs actions in attribute
order and the trigger lists its toggle first, so your handler reads
the state the toggle leaves behind, aria-expanded
included.
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
the controllers manifest and are validated at class load. Your own
controllers join that manifest with bin/rails poetry:stimulus:manifest,
which reads app/javascript/controllers and writes
config/controllers_manifest.json, the file a gem commits;
registered at boot, a host controller then validates exactly like
Poetry's: values, targets, actions and events at class load,
template wiring in poetry:check, and its API in the
registry for llms.txt and the MCP server. Generate the manifest
before you declare a controller by Symbol: a Symbol the manifest
does not know fails at class load, and the task needs the app to
boot. The reader takes controllers as they are written: comments
and strings anywhere, one-line methods, arrow-function class
fields, Windows line endings. An entry is complete or absent: a
controller extending another of yours or a Poetry controller merges
its parent; one extending a library class, or declaring a computed
static (a getter, a spread), is named by the task and stays
unvalidated until you write its entry by hand in the same file,
where regeneration keeps it. The staleness gate looks only at the
controllers the reader read: poetry:check warns and
poetry:verify fails when those drift from the sources,
never because of an entry you wrote. Events are validated only when
the controller declares static events; one that only
calls dispatch() keeps its events unvalidated rather
than wrong. A String identifier still passes through verbatim for
a controller you do not want validated, and it is also how a host
controller that shares a bare name with a Poetry one (your own
tabs_controller.js) is addressed: a Symbol always
resolves to the Poetry controller first. 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
Three 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.
A disconnect() of your own calls
super.disconnect(): every Poetry controller tears its
listeners, timers and portaled content down there, in one
per-connect scope, and a subclass that skips the call keeps them
alive. 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.
Beside Herb's reactive templates
An application on ReActionView has a second place for state: a
template declares it, and the browser owns it from there. It
shares the page with everything above. A controller of yours can
read and write that state with useState from
reactionview, and any data-herb-* action
goes through data: on a Poetry helper like a Stimulus
action does. Poetry's own controllers do not call
useState: their state stays in the attributes on the
element, where poetry:check, the server and your CSS
can see it. The
Herb and ReActionView guide
says where the two meet and where they do not yet.
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