Sheet
A dialog that slides in from a screen edge.
Installation
Included in poetry-ui — available as
poetry_sheet
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add sheet
Default
<%# The body is caller markup - px-4 aligns it with the header/footer
gutters. The footer Close is the SheetClose equivalent: any button
inside the sheet can close it by targeting the controller's close
action. %>
<%= poetry_sheet do |sheet| %>
<% sheet.with_trigger(variant: :outline) { "Open" } %>
<% sheet.with_title { "Edit profile" } %>
<% sheet.with_description { "Make changes to your profile here. Click save when you're done." } %>
<% sheet.with_footer do %>
<%= poetry_button(type: :submit) { "Save changes" } %>
<%= poetry_button(variant: :outline,
data: { action: "click->poetry--core--sheet#close" }) { "Close" } %>
<% end %>
<div class="grid flex-1 auto-rows-min gap-6 px-4">
<div class="grid gap-3">
<%= poetry_label(for_id: "sheet-demo-name") { "Name" } %>
<%= poetry_input(name: "name", value: "Pedro Duarte", id: "sheet-demo-name") %>
</div>
<div class="grid gap-3">
<%= poetry_label(for_id: "sheet-demo-username") { "Username" } %>
<%= poetry_input(name: "username", value: "@peduarte", id: "sheet-demo-username") %>
</div>
</div>
<% end %>
Form
<%# A REAL form inside the sheet: the fields live in the form element,
and the footer submit reaches it from outside via form= (the footer
is component anatomy, not form content). The demo submits as a GET
so the params land visibly in the URL. %>
<%= poetry_sheet(side: :right) do |sheet| %>
<% sheet.with_trigger(variant: :outline) { "Edit profile" } %>
<% sheet.with_title { "Edit profile" } %>
<% sheet.with_description { "Make changes to your profile here. Click save when you're done." } %>
<% sheet.with_footer do %>
<%= poetry_button(type: :submit, form: "sheet-profile-form") { "Save changes" } %>
<%= poetry_button(variant: :outline,
data: { action: "click->poetry--core--sheet#close" }) { "Close" } %>
<% end %>
<%= form_with(url: "#", method: :get, id: "sheet-profile-form",
html: { class: "grid flex-1 auto-rows-min gap-6 px-4" }) do %>
<div class="grid gap-3">
<%= poetry_label(for_id: "sheet-form-name") { "Name" } %>
<%= poetry_input(name: "name", value: "Pedro Duarte", id: "sheet-form-name") %>
</div>
<div class="grid gap-3">
<%= poetry_label(for_id: "sheet-form-username") { "Username" } %>
<%= poetry_input(name: "username", value: "@peduarte", id: "sheet-form-username") %>
</div>
<% end %>
<% end %>
No close button
<%# show_close_button: false drops the corner X - the forced-choice
posture. Both choices close by targeting the controller's close
action (in an app they would also submit the decision). %>
<%= poetry_sheet(side: :right, show_close_button: false) do |sheet| %>
<% sheet.with_trigger(variant: :outline) { "Review terms" } %>
<% sheet.with_title { "Updated usage terms" } %>
<% sheet.with_description { "Please choose an option below to continue. There is no dismiss control on this panel." } %>
<% sheet.with_footer do %>
<%= poetry_button(variant: :outline,
data: { action: "click->poetry--core--sheet#close" }) { "Decline" } %>
<%= poetry_button(data: { action: "click->poetry--core--sheet#close" }) { "Accept and continue" } %>
<% end %>
<p class="px-4">
We refreshed the sections covering data retention and export. The changes
take effect at the start of your next billing cycle.
</p>
<% end %>
Sides
<%# One sheet per edge. Top and bottom panels hug their content, capped
at half the viewport via content_class (the panel is a flex column,
so the overflow-y-auto body shrinks and scrolls inside the cap). %>
<div class="flex flex-wrap items-center gap-2">
<% %i[top right bottom left].each do |side| %>
<%= poetry_sheet(side: side,
content_class: "data-[side=top]:max-h-[50vh] data-[side=bottom]:max-h-[50vh]") do |sheet| %>
<% sheet.with_trigger(variant: :outline) { side.to_s.capitalize } %>
<% sheet.with_title { "Edit profile" } %>
<% sheet.with_description { "Make changes to your profile here. Click save when you're done." } %>
<% sheet.with_footer do %>
<%= poetry_button(type: :submit) { "Save changes" } %>
<%= poetry_button(variant: :outline,
data: { action: "click->poetry--core--sheet#close" }) { "Close" } %>
<% end %>
<div class="overflow-y-auto px-4">
<% 6.times do %>
<p class="mb-2 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</p>
<% end %>
</div>
<% end %>
<% end %>
</div>
API
Poetry::Ui::Sheet::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| side: | Symbol | one of: top, right, bottom, left; defaults to :right |
The edge the sheet slides in from - a physical direction (right stays right in RTL). |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=sheet] | Root wrapper around the trigger and the <dialog> element |
| [data-slot=sheet-content] | The <dialog> panel, anchored to a screen edge - the slide animation and the open state ride here |
| [data-slot=sheet-header] | Title block at the top of the panel |
| [data-slot=sheet-title] | The heading - the sheet's accessible name (required slot) |
| [data-slot=sheet-description] | Muted copy under the title, wired to aria-describedby |
| [data-slot=sheet-footer] | Action row pinned to the bottom of the panel |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| sheet-content | data-open | panel is open (the controller flips the pair at runtime) | — |
| sheet-content | data-closed | panel is closed or animating out (the server-rendered state; the presence-hold close rides the closed slide-out) | — |
| sheet-content | data-side | always - the edge the sheet slides in from | top · right · bottom · left |
Agent tools
What an agent may do to a rendered instance once the call opts in
(webmcp: "name") — declared beside the wiring, projected
to the registry, registered with the browser's document.modelContext
by poetry-agent.
| Tool | Parameters | Does | Dispatches |
|---|---|---|---|
| open mutating | — | Open the dialog. | poetry--core--sheet#open |
| close mutating | — | Close the dialog. | poetry--core--sheet#close |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--sheet | registers · value dismissible |
| content | poetry--core--sheet | close on cancel · backdropClose on click · target dialog |
| trigger | poetry--core--sheet | open |
| close | poetry--core--sheet | close |