Sheet
Stable Stimulus:senren--sheet
side panels
Edit profile
Update your account details. Changes save when you close the sheet.
Usage example #
Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.
app/views/.../sheet_example.html.erb
<div class="flex w-full justify-center">
<%= render Senren::SheetComponent.new(variant: :right) do |s| %>
<% s.with_trigger do %>
<%= render(Senren::ButtonComponent.new(type: :button, variant: :secondary)) { "Open sheet" } %>
<% end %>
<% s.with_title { "Edit profile" } %>
<% s.with_description { "Update your account details. Changes save when you close the sheet." } %>
<% s.with_body do %>
<div class="space-y-3">
<%= render(Senren::LabelComponent.new(for_field: "sheet_name")) { "Display name" } %>
<%= render Senren::InputComponent.new(name: "name", id: "sheet_name", value: "Senren") %>
</div>
<% end %>
<% s.with_footer do %>
<button type="button" data-action="click->senren--sheet#close" class="cursor-pointer inline-flex h-10 items-center rounded-md px-4 text-sm">Cancel</button>
<%= render(Senren::ButtonComponent.new(variant: :primary)) { "Save" } %>
<% end %>
<% end %>
</div>
Install this component #
Copy the official component into your app
This component requires Stimulus. Keep --client so the controller is copied with the ViewComponent.
Terminal
bin/rails senren:add sheet --client
Create a custom component with the same conventions
Use this when you need an app-specific component that follows Senren's ViewComponent and Stimulus structure. --client is required for this behavior.
Terminal
bin/rails generate senren:component sheet --client
Dependencies are resolved by senren:add:
button.
At a glance #
Category
Overlays
Class name
Senren::SheetComponent
Stimulus
senren--sheet
Variants
right, left, top, bottom
Depends on
button
Pairs with
form
Source #
app/components/senren/sheet_component.html.erb
<%# Root goes through root_attrs so a caller's class: and data: reach it.
Hand-writing these attributes dropped both -- which is why a Stimulus
value could not be set from the server. %>
<%= tag.div(**wrapper_attrs(id: dom_id, data: { controller: "senren--sheet" })) do %>
<% if trigger? %>
<span data-action="click->senren--sheet#open" data-senren--sheet-target="trigger">
<%= trigger %>
</span>
<% else %>
<button type="button" data-action="click->senren--sheet#open" data-senren--sheet-target="trigger" class="hidden">Open</button>
<% end %>
<div data-senren--sheet-target="overlay" hidden
data-action="click->senren--sheet#close"
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"></div>
<div data-senren--sheet-target="panel" hidden role="dialog" aria-modal="true"
data-open="false"
class="<%= panel_class("fixed z-50 bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] shadow-lg transition-transform duration-200 #{ self.class::VARIANTS[variant] }") %>">
<div class="flex h-full flex-col p-6">
<% if title? %>
<h2 class="text-lg font-semibold"><%= title %></h2>
<% end %>
<% if description? %>
<p class="text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
<% end %>
<%# -mx-2 px-2 gives the scroll container room for focus rings.
overflow-auto clips whatever draws outside a child's border box,
and a ring does exactly that -- a focused input sitting flush
against the edge had its ring shaved off on both sides. The
negative margin cancels the padding, so nothing moves. %>
<div class="-mx-2 mt-4 flex-1 overflow-auto px-2">
<%= body || content %>
</div>
<% if footer? %>
<div class="mt-4 border-t border-[hsl(var(--senren-border))] pt-4">
<%= footer %>
</div>
<% end %>
</div>
<button type="button" data-action="click->senren--sheet#close" aria-label="Close"
class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
<span aria-hidden="true">×</span>
</button>
</div>
<% end %>
app/components/senren/sheet_component.rb
# frozen_string_literal: true
module Senren
# Two ancestor traps to know about, because neither is visible in this file
# and both look like a z-index bug from the outside:
#
# * A `z-index` on any positioned ancestor creates a stacking context, and
# a fixed overlay cannot escape one. The panel then competes only inside
# that context, so a sticky header with a lower z-index still covers it.
# `position: relative` alone is fine; it is the z-index that traps.
# * `transform`, `filter`, `backdrop-filter`, `will-change` and an opacity
# below 1 create a stacking context too, with the same effect.
#
# Symptom in both cases: raising the panel's z-index changes nothing.
class SheetComponent < BaseComponent
renders_one :trigger
renders_one :title
renders_one :description
renders_one :body
renders_one :footer
VARIANTS = {
right: 'right-0 top-0 h-full w-full max-w-md translate-x-full data-[open=true]:translate-x-0',
left: 'left-0 top-0 h-full w-full max-w-md -translate-x-full data-[open=true]:translate-x-0',
top: 'left-0 top-0 w-full h-1/2 -translate-y-full data-[open=true]:translate-y-0',
bottom: 'left-0 bottom-0 w-full h-1/2 translate-y-full data-[open=true]:translate-y-0'
}.freeze
SIZES = { md: '' }.freeze
def initialize(side: :right, id: nil, class_name: nil, **html)
super(variant: side, size: :md, class_name: class_name, **html)
@dom_id = id || senren_dom_id(side)
end
attr_reader :dom_id
end
end
AI agent rules #
Use for
- +side panels
- +mobile filters
- +edit panels
Avoid
- -destructive confirmation - use alert_dialog
Accessibility #
- Trap focus when open.
- Escape to close.
- aria-modal=true while open.