Capas
Estado: EstableHoja lateral
El panel para filtros del admin, el detalle de una reservación o el carrito en móvil. Comparte motor con el modal (dialog nativo, velo con desenfoque, foco atrapado y devuelto, Esc, fondo y X) pero entra deslizándose desde su lado y se cierra con el gesto: sigue al dedo, cede con resistencia si se jala hacia adentro, se va por distancia o por velocidad y, si no, regresa con un rebote. En móvil la hoja lateral pasa a ser hoja inferior, con asa.
- dialog nativo
- Arrastre
- Resorte
- Móvil
app/views/… (la demo de arriba)
<% applied = params[:filtros] %>
<div class="flex w-full max-w-sm flex-col items-center gap-5">
<div class="flex flex-wrap items-center justify-center gap-2">
<%# 1. Filtros del admin: un form GET que se cierra solo al aplicar. En
móvil la hoja derecha pasa a ser inferior. %>
<%= render Ui::SheetComponent.new(id: "sheet-filters", title: "Filtrar reservaciones", description: "Se aplican a la tabla y a la exportación.", close_on_submit: true) do |sheet| %>
<% sheet.with_trigger do %>
<%= render Ui::ButtonComponent.new(variant: :secondary) do %>
<%= render Ui::IconComponent.new(:list_filter) %>
Filtros
<% end %>
<% end %>
<%= form_with url: ui_component_path("sheet"), method: :get, scope: :filtros, id: "sheet-filters-form", data: { turbo_frame: "sheet-result" }, class: "grid gap-5 pt-1" do |f| %>
<fieldset class="grid gap-2">
<legend class="mb-2 text-sm font-medium text-foreground">Estado</legend>
<div class="grid grid-cols-3 gap-1 rounded-lg border border-border bg-raised p-1">
<% [ [ "", "Todas" ], [ "confirmed", "Confirmadas" ], [ "cancelled", "Canceladas" ] ].each do |value, label| %>
<label class="relative">
<%= f.radio_button :status, value, checked: value == "confirmed", class: "peer sr-only" %>
<span class="block cursor-pointer rounded-md px-2 py-1.5 text-center text-xs font-medium text-muted-foreground transition-colors duration-quick hover:text-foreground peer-checked:bg-card peer-checked:text-foreground peer-checked:shadow-subtle peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--eui-focus,color-mix(in_oklab,var(--color-primary)_60%,transparent))]"><%= label %></span>
</label>
<% end %>
</div>
</fieldset>
<%= render Ui::FieldComponent.new(label: "Clase", form: f, attribute: :class_name) do |field| %>
<%= f.select :class_name, [ "Todas las clases", "Vinyasa Flow", "Reformer", "Barre", "Yin" ], { selected: "Reformer" }, class: Ui::FieldComponent.control_classes, **field.control_attributes %>
<% end %>
<%= render Ui::FieldComponent.new(label: "Maestra", form: f, attribute: :teacher) do |field| %>
<%= f.select :teacher, [ "Todas", "Mariana Ortiz", "Ana López", "Iván Reyes" ], {}, class: Ui::FieldComponent.control_classes, **field.control_attributes %>
<% end %>
<div class="grid grid-cols-2 gap-3">
<%= render Ui::FieldComponent.new(label: "Desde", form: f, attribute: :from) do |field| %>
<%= f.date_field :from, value: "2026-10-13", class: Ui::FieldComponent.control_classes(extra: "font-mono"), **field.control_attributes %>
<% end %>
<%= render Ui::FieldComponent.new(label: "Hasta", form: f, attribute: :to) do |field| %>
<%= f.date_field :to, value: "2026-10-19", class: Ui::FieldComponent.control_classes(extra: "font-mono"), **field.control_attributes %>
<% end %>
</div>
<%= render Ui::CheckboxComponent.new(name: "filtros[waitlist]", unchecked_value: "0", label: "Con lista de espera",
description: "Solo clases llenas con clientes esperando lugar.", class: "rounded-xl border border-border bg-raised px-3.5 py-3") %>
<% end %>
<% sheet.with_footer do %>
<%= render Ui::ButtonComponent.new(variant: :ghost, type: "reset", form: "sheet-filters-form") do %>Limpiar<% end %>
<%= render Ui::ButtonComponent.new(type: "submit", form: "sheet-filters-form") do %>Aplicar filtros<% end %>
<% end %>
<% end %>
<%# 2. Detalle de una reservación desde abajo (con asa). %>
<%= render Ui::SheetComponent.new(id: "sheet-reservation", side: :bottom, size: :sm, title: "Reservación #1042", description: "Ana López · reservó el lun 12 oct, 21:14") do |sheet| %>
<% sheet.with_trigger do %>
<%= render Ui::ButtonComponent.new(variant: :ghost) do %>Ver reservación<% end %>
<% end %>
<div class="grid gap-4">
<div class="flex items-center justify-between gap-3 rounded-xl border border-border bg-raised px-4 py-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-foreground">Reformer intermedio</p>
<p class="truncate text-xs text-muted-foreground">con Iván Reyes · Sala 2</p>
</div>
<%= render Ui::StatusPillComponent.new(status: "confirmed") %>
</div>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div><dt class="eui-label">Fecha</dt><dd class="mt-1 text-foreground">mié 14 oct</dd></div>
<div><dt class="eui-label">Hora</dt><dd class="mt-1 font-mono text-foreground">07:00 – 07:50</dd></div>
<div><dt class="eui-label">Lugar</dt><dd class="mt-1 font-mono text-foreground">Cama 6</dd></div>
<div><dt class="eui-label">Crédito</dt><dd class="mt-1 text-foreground">10 clases</dd><dd class="text-xs text-muted-foreground">quedan <span class="font-mono">4</span></dd></div>
</dl>
</div>
<% sheet.with_footer do %>
<%= render Ui::ButtonComponent.new(variant: :secondary, data: { action: "sheet#close" }) do %>Listo<% end %>
<% end %>
<% end %>
</div>
<%= turbo_frame_tag "sheet-result", class: "block min-h-5 text-center text-xs text-muted-foreground" do %>
<% if applied %>
<span class="text-foreground">Filtros aplicados</span> ·
<span class="font-mono"><%= [ { "confirmed" => "Confirmadas", "cancelled" => "Canceladas" }[applied[:status]] || "Todas", applied[:class_name], applied[:teacher] ].compact_blank.join(" · ") %></span>
<% else %>
Arrastra la hoja hacia su lado para cerrarla
<% end %>
<% end %>
</div>Qué hace
- Cierra al soltar pasado un tercio de su tamaño o con un tirón de más de 0.5 px/ms; si no, regresa con el resorte responsive.
- Hacia adentro opone resistencia decreciente; el velo se aclara a la par del arrastre.
- La hoja inferior se arrastra desde el asa, el encabezado o el pie; desde el cuerpo solo si no tiene scroll.
- Flota a 8px del borde (y respeta la zona segura del iPhone), así el rebote nunca deja ver un hueco.
- Mismas entradas y eventos que el modal: sheet#open, data-eui-open, eui:open; emite sheet:open y sheet:close.
Props
| Nombre | Tipo | Default | Descripción |
|---|---|---|---|
| id | String | aleatorio | Id del <dialog>; ábrelo desde cualquier parte con data-eui-open="<id>". |
| side | :right | :left | :bottom | :right | Lado desde el que entra y hacia el que se arrastra para cerrar. |
| adaptive | Boolean | true | En pantallas de menos de 640px, :right y :left se vuelven hoja inferior. |
| size | :sm | :md | :lg | :md | Lateral: 20, 24 o 30rem de ancho. Inferior: 28, 36 o 48rem de ancho máximo. |
| width | String (CSS) | nil | Ancho exacto (p. ej. "28rem"); gana sobre size. |
| title / description | String | slot | — | Encabezado fijo; nombra y describe al diálogo. |
| with_trigger / with_footer | slot | nil | El botón que la abre y las acciones fijas abajo. |
| dismissible | Boolean | true | false: ni el fondo, ni Esc, ni el arrastre la cierran (cede y regresa). |
| close_on_submit | Boolean | false | Se cierra tras un turbo:submit-end exitoso (aplicar filtros). |
| src / frame_id | String | nil | Cuerpo remoto en un turbo-frame lazy, como en el modal. |
| open / remove_on_close | Boolean | false | Abrirla al conectarse (Turbo Stream) y quitarla al cerrar. |
Accesibilidad
- Es un <dialog> modal; el arrastre es un extra, siempre se cierra con la X, Esc o el fondo.
- El título y la descripción nombran y describen la hoja; el foco regresa al disparador.
- Los controles de adentro (botones, campos, enlaces) nunca inician un arrastre.
- Con movimiento reducido aparece y se va sin deslizarse.