Menú de acciones

Las acciones por fila de las tablas del admin (Ver, Editar, Duplicar, Cancelar clase, Eliminar) o el menú de la cuenta. Crece desde el botón con resorte, flota en la capa superior (el overflow de la tabla no lo recorta), se voltea si no cabe y un resaltado se desliza a la opción bajo el cursor o el teclado. Enlaces, botones y envíos reales con button_to y confirmación.

Clases de mañana
Clase Hora Quién la da Reservas Acciones
Vinyasa Flow 07:00 Ana García 9/12
Reformer Intermedio 09:30 Sofía Ruiz 10/10
Mat Pilates 19:00 Ana García 5/12

Elige una acción de cualquier fila

app/views/… (la demo de arriba)

<%# Sesiones de ejemplo (Ui::Demo::Session), sin consultas. En tu app las
    acciones apuntan a sus rutas y cancelar es
      m.with_item_button_to("Cancelar clase", session_path(s), method: :delete, confirm: "…")
    Aquí cada envío manda un GET a esta ficha que actualiza el frame de abajo;
    si cancelas la confirmación, no se envía. %>
<%= render Ui::ConfirmDialogComponent.new(id: "dropdown-menu-confirm") %>
<% tomorrow = Date.current + 1 %>
<% clases = [
     [ 101, "Vinyasa Flow", "Ana García", "07:00", 12, 9 ],
     [ 102, "Reformer Intermedio", "Sofía Ruiz", "09:30", 10, 10 ],
     [ 103, "Mat Pilates", "Ana García", "19:00", 12, 5 ]
   ].map do |id, name, host, at, capacity, booked|
     Ui::Demo::Session.new(id:, name:, host:, starts_at: Time.zone.parse("#{tomorrow} #{at}"), capacity:, booked:)
   end %>
<% demo_form = { data: { turbo_frame: "dropdown-menu-result" } } %>

<div class="flex w-full max-w-xl flex-col items-center gap-5">
  <%= render Ui::DataTableComponent.new(rows: clases, caption: "Clases de mañana", density: :compact, class: "w-full") do |t| %>
    <% t.with_column("Clase") { |sc| tag.span(sc.name, class: "font-medium text-foreground") } %>
    <% t.with_column("Hora", numeric: true) { |sc| sc.starts_at.strftime("%H:%M") } %>
    <% t.with_column("Quién la da", hide_below: :md) { |sc| sc.host } %>
    <% t.with_column("Reservas", numeric: true, hide_below: :sm) { |sc| "#{sc.booked}/#{sc.capacity}" } %>
    <% t.with_column("Acciones", header_hidden: true, align: :right, width: "w-12") do |sc| %>
      <% booked = sc.booked %>
      <%= render Ui::DropdownMenuComponent.new(label: "Acciones de #{sc.name}", align: :end) do |m| %>
        <% m.with_item_link("Ver reservas", href: "#dropdown-menu-ver-#{sc.id}", icon: :eye, hint: "#{booked}/#{sc.capacity}") %>
        <% m.with_item_link("Editar", href: "#dropdown-menu-editar-#{sc.id}", icon: :pencil) %>
        <% m.with_item_button_to("Duplicar a la próxima semana", ui_component_path("dropdown-menu"), method: :get, icon: :copy,
                                 params: { hecho: "#{sc.name} duplicada al #{I18n.l(tomorrow + 7, format: "%-d %b")}" }, form: demo_form) %>
        <% m.with_item_button("Copiar enlace", icon: :link,
                              data: { controller: "clipboard", clipboard_text_value: "https://lunaestudio.mx/clases/#{sc.id}", action: "clipboard#copy" }) %>
        <% m.with_item_button("Check-in", icon: :check_in, disabled: true, hint: "Mañana") %>
        <% m.with_item_separator %>
        <% m.with_item_button_to("Cancelar clase", ui_component_path("dropdown-menu"), method: :get, icon: :ban, tone: :danger,
                                 params: { hecho: "#{sc.name} cancelada" }, form: demo_form,
                                 confirm: "¿Cancelar #{sc.name}? Se avisará a las #{booked} personas con reserva.") %>
        <% m.with_item_button_to("Eliminar", ui_component_path("dropdown-menu"), method: :get, icon: :trash, tone: :danger,
                                 params: { hecho: "#{sc.name} eliminada" }, form: demo_form,
                                 confirm: "¿Eliminar #{sc.name}? Esta acción no se puede deshacer.") %>
      <% end %>
    <% end %>
  <% end %>

  <%= turbo_frame_tag "dropdown-menu-result", class: "block min-h-4 text-center text-xs text-muted-foreground" do %>
    <% if params[:hecho].present? %>
      <span class="text-foreground"><%= params[:hecho] %></span> · <span class="font-mono"><%= Time.current.strftime("%H:%M:%S") %></span>
    <% else %>
      Elige una acción de cualquier fila
    <% end %>
  <% end %>

  <%# Menú de la cuenta: el slot trigger es el contenido del botón. En la app,
      "Cerrar sesión" es href: destroy_user_session_path, method: :delete. %>
  <%= render Ui::DropdownMenuComponent.new(label: "Cuenta de Ana García", width: "w-60") do |m| %>
    <% m.with_trigger do %>
      <span class="grid size-6 shrink-0 place-items-center rounded-full border border-border-subtle bg-raised text-3xs font-semibold text-muted-foreground" aria-hidden="true">AG</span>
      <span class="truncate">Ana García</span>
    <% end %>
    <% m.with_header do %>
      <p class="truncate text-sm font-medium text-foreground">Ana García</p>
      <p class="truncate font-mono text-2xs text-muted-foreground">ana.garcia@lunaestudio.mx</p>
    <% end %>
    <% m.with_item_label("Cuenta") %>
    <% m.with_item_link("Mi perfil", href: "#dropdown-menu-perfil", icon: :user) %>
    <% m.with_item_link("Mi paquete", href: "#dropdown-menu-paquete", icon: :card, hint: "4 créditos") %>
    <% m.with_item_label("Estudio") %>
    <% m.with_item_link("Panel de admin", href: "#dropdown-menu-admin", icon: :dashboard) %>
    <% m.with_item_separator %>
    <% m.with_item_link("Cerrar sesión", href: "#dropdown-menu-salir", icon: :logout) %>
  <% end %>
</div>

Qué hace

  • Popover nativo en la capa superior, colocado con eclipse_ui/anchor; sigue al botón al desplazar la tabla.
  • Entra con el resorte snappy desde el borde del botón (--eui-origin) y sale con una transición corta, también cuando lo cierra el navegador.
  • El resaltado se desliza con resorte entre opciones, con cursor o teclado; en las destructivas toma el tono de peligro.
  • Cierra al elegir antes de que actúe la opción, así el diálogo de confirmación devuelve el foco al botón.
  • Dispara dropdown-menu:open, dropdown-menu:close y dropdown-menu:select ({ item, label }).

Props

Nombre Tipo Default Descripción
label String (obligatorio) Nombre accesible del botón (aria-label) y del menú. Con trigger propio, que incluya el texto visible.
align :start | :center | :end :start Alineación con el botón. :end para la última columna de una tabla.
side :bottom | :top :bottom Lado preferido; se voltea solo si no cabe.
width String (clase) "min-w-48" Ancho del panel, p. ej. "w-60".
trigger_class String — Clases del botón si no quieres las de serie.
chevron Boolean true Chevron que gira en el trigger propio.
with_trigger slot tres puntos Contenido del botón (avatar + nombre, etiqueta…), solo contenido en línea.
with_header slot — Encabezado sobre las opciones (nombre y correo); el menú lo usa como descripción.
with_item_link (texto, href:, method:, …) — Enlace; method: :delete lo vuelve data-turbo-method.
with_item_button (texto, data:, …) — Botón para acciones del cliente (data: { action: … }).
with_item_button_to (texto, url, method:, params:, form:, …) — Envío real con <form>. confirm: o data: { turbo_confirm:, confirm_*: } van al botón (el submitter que lee Turbo).
with_item_separator slot — Línea divisoria (también with_item_divider).
with_item_label (texto) — Título de grupo; agrupa lo que sigue (role=group).
icon: / hint: / shortcut: Symbol | String — Por ítem: ícono (:eye, :pencil, :copy, :link, :ban, :trash, :user, :card, :dashboard, :logout…), texto a la derecha o atajo en <kbd>.
tone: / disabled: :danger / Boolean — Por ítem: acción destructiva (:destructive también sirve) o deshabilitada (aria-disabled; el teclado la salta).

Accesibilidad