Hoja 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.

Filtrar reservaciones

Se aplican a la tabla y a la exportación.

Estado

Reservación #1042

Ana López · reservó el lun 12 oct, 21:14

Reformer intermedio

con Iván Reyes · Sala 2

Confirmada
Fecha
mié 14 oct
Hora
07:00 – 07:50
Lugar
Cama 6
Crédito
10 clases
quedan 4
Arrastra la hoja hacia su lado para cerrarla

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